openepcis-client 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.
- openepcis_client/__init__.py +5 -0
- openepcis_client/core/__init__.py +7 -0
- openepcis_client/core/auth.py +335 -0
- openepcis_client/core/client.py +255 -0
- openepcis_client/core/config.py +30 -0
- openepcis_client/core/errors.py +73 -0
- openepcis_client/core/gs1.py +220 -0
- openepcis_client/events/__init__.py +53 -0
- openepcis_client/events/cbv.py +102 -0
- openepcis_client/events/document.py +669 -0
- openepcis_client/events/service.py +312 -0
- openepcis_client/masterdata/__init__.py +20 -0
- openepcis_client/masterdata/payload.py +91 -0
- openepcis_client/masterdata/service.py +221 -0
- openepcis_client/masterdata/vocabulary.json +188 -0
- openepcis_client/masterdata/vocabulary.py +172 -0
- openepcis_client/py.typed +0 -0
- openepcis_client/registry/__init__.py +7 -0
- openepcis_client/registry/service.py +233 -0
- openepcis_client-0.1.0.dist-info/METADATA +137 -0
- openepcis_client-0.1.0.dist-info/RECORD +24 -0
- openepcis_client-0.1.0.dist-info/WHEEL +4 -0
- openepcis_client-0.1.0.dist-info/licenses/LICENSE +201 -0
- openepcis_client-0.1.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 benelog GmbH & Co. KG
|
|
3
|
+
"""Authentication strategies and the discovery that feeds them.
|
|
4
|
+
|
|
5
|
+
The platform authenticates connectors with an OIDC offline token: a refresh
|
|
6
|
+
token issued with the ``offline_access`` scope, exchanged for short-lived
|
|
7
|
+
access tokens as needed. Issuing the offline token is a human act in a
|
|
8
|
+
browser; this library only consumes one.
|
|
9
|
+
|
|
10
|
+
Discovery removes the last hand-typed URL besides the resolver's own. The
|
|
11
|
+
resolver publishes OAuth 2.0 Protected Resource Metadata (RFC 9728) naming its
|
|
12
|
+
authorization server; the authorization server's OIDC discovery document names
|
|
13
|
+
the token endpoint. One configured URL, everything else is found.
|
|
14
|
+
|
|
15
|
+
The seam for a second mode (a customer-supplied GS1 Germany token, or anything
|
|
16
|
+
after that) is :class:`AuthStrategy`: one method that yields a bearer, one
|
|
17
|
+
that forgets it after a 401.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import binascii
|
|
22
|
+
import json
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any, Protocol
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
from .config import ClientConfig
|
|
29
|
+
from .errors import OpenEpcisError
|
|
30
|
+
|
|
31
|
+
#: Mint a new access token this many seconds before the current one lapses, so
|
|
32
|
+
#: a request never starts with a token that expires mid-flight.
|
|
33
|
+
EXPIRY_MARGIN = 30
|
|
34
|
+
|
|
35
|
+
#: Authorization servers discovered from a resolver's protected-resource
|
|
36
|
+
#: metadata, keyed by resolver base URL. Class-wide and unbounded on purpose: a
|
|
37
|
+
#: process talks to a handful of resolvers, and the answer changes only when a
|
|
38
|
+
#: deployment is reconfigured. A restart is an acceptable cache invalidation.
|
|
39
|
+
_PROTECTED_RESOURCE: dict[str, str] = {}
|
|
40
|
+
|
|
41
|
+
#: OIDC discovery documents, keyed by issuer. Same lifetime argument.
|
|
42
|
+
_OIDC_CONFIG: dict[str, dict[str, Any]] = {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TokenStore(Protocol):
|
|
46
|
+
"""Where the host keeps the offline token and what is known about it.
|
|
47
|
+
|
|
48
|
+
The host framework owns persistence: an ERP stores the token on a company
|
|
49
|
+
record, a script keeps it in memory or a secret store. Two of the three
|
|
50
|
+
methods exist because of Keycloak behaviour the library must honour:
|
|
51
|
+
rotation ("Revoke Refresh Token") replaces the offline token on every
|
|
52
|
+
exchange, and losing the replacement locks the connector out with a
|
|
53
|
+
credential that still looks correct on screen.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def get_offline_token(self) -> str:
|
|
57
|
+
"""The current offline token. Read on every mint, so rotation holds."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def save_offline_token(self, token: str) -> None:
|
|
61
|
+
"""Persist a rotated offline token the moment it arrives."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
def save_subject(self, username: str) -> None:
|
|
65
|
+
"""Record whom the token belongs to, for display next to the setting."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AuthStrategy(Protocol):
|
|
70
|
+
"""A source of bearer tokens for :class:`~openepcis_client.core.client.Client`."""
|
|
71
|
+
|
|
72
|
+
def bearer(self) -> str:
|
|
73
|
+
"""A currently valid access token."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def invalidate(self) -> None:
|
|
77
|
+
"""Forget any cached token; the next :meth:`bearer` mints afresh."""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InMemoryTokenStore:
|
|
82
|
+
"""A token store for scripts and tests. Nothing outlives the process."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, offline_token: str) -> None:
|
|
85
|
+
self.offline_token = offline_token
|
|
86
|
+
self.subject = ""
|
|
87
|
+
|
|
88
|
+
def get_offline_token(self) -> str:
|
|
89
|
+
return self.offline_token
|
|
90
|
+
|
|
91
|
+
def save_offline_token(self, token: str) -> None:
|
|
92
|
+
self.offline_token = token
|
|
93
|
+
|
|
94
|
+
def save_subject(self, username: str) -> None:
|
|
95
|
+
self.subject = username
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def token_claims(token: str) -> dict[str, Any]:
|
|
99
|
+
"""The claims of a JWT, read without verifying it.
|
|
100
|
+
|
|
101
|
+
Only ever used to describe a token back to whoever deposited it. Nothing is
|
|
102
|
+
authorised on this basis; the platform and Keycloak do the verifying.
|
|
103
|
+
Answers an empty dict for anything opaque.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
payload = token.split(".")[1]
|
|
107
|
+
payload += "=" * (-len(payload) % 4)
|
|
108
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
109
|
+
except (IndexError, ValueError, binascii.Error, UnicodeDecodeError):
|
|
110
|
+
return {}
|
|
111
|
+
return claims if isinstance(claims, dict) else {}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def token_type(token: str) -> str:
|
|
115
|
+
"""The ``typ`` claim: ``Offline`` for the token this library expects."""
|
|
116
|
+
return str(token_claims(token).get("typ") or "")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def token_subject(token: str) -> str:
|
|
120
|
+
"""Whom a token belongs to, as well as it can be told."""
|
|
121
|
+
claims = token_claims(token)
|
|
122
|
+
return str(claims.get("preferred_username") or claims.get("sub") or "")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class OfflineTokenAuth:
|
|
126
|
+
"""Mode one: OpenEPCIS credentials via an OIDC offline token.
|
|
127
|
+
|
|
128
|
+
:param config: where the resolver is and how patient to be.
|
|
129
|
+
:param token_store: the host's persistence for the offline token.
|
|
130
|
+
:param client_id: the OIDC client this connector authenticates as.
|
|
131
|
+
:param client_secret: only for a confidential client.
|
|
132
|
+
:param issuer_override: skip RFC 9728 discovery and use this realm URL.
|
|
133
|
+
For a resolver that publishes no protected-resource metadata.
|
|
134
|
+
:param session: a ``requests``-compatible session, injectable for tests.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
config: ClientConfig,
|
|
140
|
+
token_store: TokenStore,
|
|
141
|
+
client_id: str,
|
|
142
|
+
client_secret: str = "",
|
|
143
|
+
issuer_override: str = "",
|
|
144
|
+
session: requests.Session | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
self._config = config
|
|
147
|
+
self._store = token_store
|
|
148
|
+
self._client_id = client_id
|
|
149
|
+
self._client_secret = client_secret
|
|
150
|
+
self._issuer_override = issuer_override.rstrip("/")
|
|
151
|
+
self._session = session or requests.Session()
|
|
152
|
+
self._access_token = ""
|
|
153
|
+
self._expires_at = 0.0
|
|
154
|
+
|
|
155
|
+
# -- AuthStrategy ------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def bearer(self) -> str:
|
|
158
|
+
if self._access_token and self._expires_at > time.time():
|
|
159
|
+
return self._access_token
|
|
160
|
+
token, lifetime = self._mint()
|
|
161
|
+
self._access_token = token
|
|
162
|
+
self._expires_at = time.time() + max(lifetime - EXPIRY_MARGIN, 5)
|
|
163
|
+
return token
|
|
164
|
+
|
|
165
|
+
def invalidate(self) -> None:
|
|
166
|
+
self._access_token = ""
|
|
167
|
+
self._expires_at = 0.0
|
|
168
|
+
|
|
169
|
+
# -- Discovery ---------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def issuer(self) -> str:
|
|
172
|
+
"""The authorization server, configured or discovered (RFC 9728)."""
|
|
173
|
+
if self._issuer_override:
|
|
174
|
+
return self._issuer_override
|
|
175
|
+
base_url = self._config.base_url
|
|
176
|
+
if base_url in _PROTECTED_RESOURCE:
|
|
177
|
+
return _PROTECTED_RESOURCE[base_url]
|
|
178
|
+
|
|
179
|
+
url = f"{base_url}/.well-known/oauth-protected-resource"
|
|
180
|
+
try:
|
|
181
|
+
response = self._session.get(url, timeout=self._config.token_timeout)
|
|
182
|
+
except requests.exceptions.RequestException as exc:
|
|
183
|
+
raise OpenEpcisError(f"Could not reach the resolver at {url}: {exc}") from exc
|
|
184
|
+
if response.status_code >= 300:
|
|
185
|
+
raise OpenEpcisError(
|
|
186
|
+
f"The resolver at {base_url} does not publish OAuth metadata "
|
|
187
|
+
f"({url} answered {response.status_code}). Configure the realm "
|
|
188
|
+
"URL explicitly.",
|
|
189
|
+
status=response.status_code,
|
|
190
|
+
)
|
|
191
|
+
try:
|
|
192
|
+
servers = (response.json() or {}).get("authorization_servers") or []
|
|
193
|
+
except ValueError as exc:
|
|
194
|
+
raise OpenEpcisError(
|
|
195
|
+
f"The resolver at {url} returned no OAuth metadata document."
|
|
196
|
+
) from exc
|
|
197
|
+
if not servers:
|
|
198
|
+
raise OpenEpcisError(
|
|
199
|
+
f"The resolver at {url} names no authorization server in its OAuth metadata."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
issuer = str(servers[0]).rstrip("/")
|
|
203
|
+
_PROTECTED_RESOURCE[base_url] = issuer
|
|
204
|
+
return issuer
|
|
205
|
+
|
|
206
|
+
def _oidc_config(self) -> dict[str, Any]:
|
|
207
|
+
"""The realm's discovery document, so no endpoint is hardcoded."""
|
|
208
|
+
issuer = self.issuer()
|
|
209
|
+
if issuer in _OIDC_CONFIG:
|
|
210
|
+
return _OIDC_CONFIG[issuer]
|
|
211
|
+
|
|
212
|
+
url = f"{issuer}/.well-known/openid-configuration"
|
|
213
|
+
try:
|
|
214
|
+
response = self._session.get(url, timeout=self._config.token_timeout)
|
|
215
|
+
except requests.exceptions.RequestException as exc:
|
|
216
|
+
raise OpenEpcisError(f"Could not reach the realm at {url}: {exc}") from exc
|
|
217
|
+
if response.status_code >= 300:
|
|
218
|
+
raise OpenEpcisError(
|
|
219
|
+
f"The realm URL does not look like an OIDC realm: {url} answered "
|
|
220
|
+
f"{response.status_code}. It should end in /realms/<name>.",
|
|
221
|
+
status=response.status_code,
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
document = response.json()
|
|
225
|
+
except ValueError as exc:
|
|
226
|
+
raise OpenEpcisError(
|
|
227
|
+
f"The realm at {url} did not return a discovery document."
|
|
228
|
+
) from exc
|
|
229
|
+
if not isinstance(document, dict):
|
|
230
|
+
raise OpenEpcisError(f"The realm at {url} did not return a discovery document.")
|
|
231
|
+
|
|
232
|
+
_OIDC_CONFIG[issuer] = document
|
|
233
|
+
return document
|
|
234
|
+
|
|
235
|
+
# -- Token exchange ------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def _mint(self) -> tuple[str, int]:
|
|
238
|
+
"""Exchange the offline token for an access token.
|
|
239
|
+
|
|
240
|
+
Honours rotation: when the realm has "Revoke Refresh Token" switched
|
|
241
|
+
on, Keycloak replaces the offline token on every exchange, and the
|
|
242
|
+
replacement goes straight to the :class:`TokenStore`. Failing to store
|
|
243
|
+
it would lock the connector out at the next refresh with a credential
|
|
244
|
+
that still looks unchanged where it was pasted.
|
|
245
|
+
"""
|
|
246
|
+
endpoint = self._oidc_config().get("token_endpoint")
|
|
247
|
+
if not endpoint:
|
|
248
|
+
raise OpenEpcisError("The realm advertises no token endpoint.")
|
|
249
|
+
|
|
250
|
+
offline_token = self._store.get_offline_token()
|
|
251
|
+
payload = {
|
|
252
|
+
"grant_type": "refresh_token",
|
|
253
|
+
"refresh_token": offline_token,
|
|
254
|
+
"client_id": self._client_id,
|
|
255
|
+
}
|
|
256
|
+
if self._client_secret:
|
|
257
|
+
payload["client_secret"] = self._client_secret
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
response = self._session.post(
|
|
261
|
+
str(endpoint), data=payload, timeout=self._config.token_timeout
|
|
262
|
+
)
|
|
263
|
+
except requests.exceptions.RequestException as exc:
|
|
264
|
+
raise OpenEpcisError(f"Could not reach the token endpoint: {exc}") from exc
|
|
265
|
+
|
|
266
|
+
if response.status_code >= 300:
|
|
267
|
+
raise self._token_error(response)
|
|
268
|
+
|
|
269
|
+
body = response.json()
|
|
270
|
+
access_token = body.get("access_token")
|
|
271
|
+
if not access_token:
|
|
272
|
+
raise OpenEpcisError("The token endpoint returned no access token.")
|
|
273
|
+
|
|
274
|
+
rotated = body.get("refresh_token")
|
|
275
|
+
if rotated and rotated != offline_token:
|
|
276
|
+
self._store.save_offline_token(str(rotated))
|
|
277
|
+
|
|
278
|
+
# An offline refresh token carries no preferred_username, so a token
|
|
279
|
+
# deposited by hand can only be labelled with its subject UUID. The
|
|
280
|
+
# access token does carry it; correct the label the first time one is
|
|
281
|
+
# minted, which is when the information becomes available.
|
|
282
|
+
username = token_claims(str(access_token)).get("preferred_username")
|
|
283
|
+
if username:
|
|
284
|
+
self._store.save_subject(str(username))
|
|
285
|
+
|
|
286
|
+
return str(access_token), int(body.get("expires_in") or 60)
|
|
287
|
+
|
|
288
|
+
@staticmethod
|
|
289
|
+
def _token_error(response: requests.Response) -> OpenEpcisError:
|
|
290
|
+
"""Phrase a token failure as something an operator can act on."""
|
|
291
|
+
detail: dict[str, Any] = {}
|
|
292
|
+
try:
|
|
293
|
+
body = response.json()
|
|
294
|
+
if isinstance(body, dict):
|
|
295
|
+
detail = body
|
|
296
|
+
except ValueError:
|
|
297
|
+
pass
|
|
298
|
+
code = str(detail.get("error") or "")
|
|
299
|
+
description = str(detail.get("error_description") or "")
|
|
300
|
+
|
|
301
|
+
if code == "invalid_grant":
|
|
302
|
+
# A token is bound to the issuer URL it was minted under. Where two
|
|
303
|
+
# hostnames serve the same realm, a token issued via one and
|
|
304
|
+
# refreshed via the other fails here; calling that "revoked" sends
|
|
305
|
+
# the reader to the wrong fix. Keycloak names the expected issuer,
|
|
306
|
+
# so pass it through.
|
|
307
|
+
if "issuer" in description.lower():
|
|
308
|
+
return OpenEpcisError(
|
|
309
|
+
"The offline token was issued by a different URL than the "
|
|
310
|
+
f"one configured here: {description}. A token is bound to "
|
|
311
|
+
"the issuer it was minted under; an alias hostname for the "
|
|
312
|
+
"same realm counts as different.",
|
|
313
|
+
status=response.status_code,
|
|
314
|
+
problem=detail,
|
|
315
|
+
)
|
|
316
|
+
return OpenEpcisError(
|
|
317
|
+
f"The offline token is no longer accepted ({description or code}). "
|
|
318
|
+
"It has been revoked, or the realm's offline session has been "
|
|
319
|
+
"removed. Deposit a fresh one.",
|
|
320
|
+
status=response.status_code,
|
|
321
|
+
problem=detail,
|
|
322
|
+
)
|
|
323
|
+
if code == "unauthorized_client":
|
|
324
|
+
return OpenEpcisError(
|
|
325
|
+
"The identity provider refused the client. Check the client ID, "
|
|
326
|
+
"and the secret if the client is confidential.",
|
|
327
|
+
status=response.status_code,
|
|
328
|
+
problem=detail,
|
|
329
|
+
)
|
|
330
|
+
return OpenEpcisError(
|
|
331
|
+
"The identity provider refused to issue a token: "
|
|
332
|
+
f"{description or code or response.text[:200]}",
|
|
333
|
+
status=response.status_code,
|
|
334
|
+
problem=detail,
|
|
335
|
+
)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 benelog GmbH & Co. KG
|
|
3
|
+
"""The HTTP client every module calls through.
|
|
4
|
+
|
|
5
|
+
Three properties are worth defending, because getting any of them wrong is
|
|
6
|
+
either a lock-out or a lie:
|
|
7
|
+
|
|
8
|
+
- a 401 is re-authorised exactly once, and that does not count as repeating
|
|
9
|
+
the operation. Otherwise a POST that was refused never gets its second
|
|
10
|
+
chance, or worse, one that succeeded gets sent twice;
|
|
11
|
+
- only idempotent verbs retry. A retried POST could publish twice or burn a
|
|
12
|
+
GS1 key;
|
|
13
|
+
- errors carry the server's own RFC 7807 ``detail`` where there is one, with
|
|
14
|
+
the structured facts alongside, and never invent a sentence the server did
|
|
15
|
+
not say.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
from .auth import AuthStrategy
|
|
26
|
+
from .config import ClientConfig
|
|
27
|
+
from .errors import OpenEpcisError
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
#: Verbs that are safe to repeat. A retried POST is not: it could create a
|
|
32
|
+
#: second record or, worse, burn a GS1 key.
|
|
33
|
+
IDEMPOTENT_METHODS = frozenset({"GET", "PUT", "DELETE", "HEAD"})
|
|
34
|
+
|
|
35
|
+
#: What this client will accept back, and why it is not just ``application/json``.
|
|
36
|
+
#:
|
|
37
|
+
#: The EPCIS capture endpoint produces ``application/ld+json`` and
|
|
38
|
+
#: ``application/problem+json`` and nothing else. Asking for plain JSON — the
|
|
39
|
+
#: obvious thing, and what this sent — is answered with **406 Not Acceptable**
|
|
40
|
+
#: before the document is even looked at, so no event from this client would
|
|
41
|
+
#: ever have been captured against a real repository. It went unnoticed because
|
|
42
|
+
#: every test here stubs the transport at the seam, and the connector that runs
|
|
43
|
+
#: in production is the Java one, which sends its own headers.
|
|
44
|
+
#:
|
|
45
|
+
#: Listing both keeps the catalog and registry endpoints, which answer plain
|
|
46
|
+
#: JSON, working as before: the server picks what it can produce.
|
|
47
|
+
ACCEPT = "application/json, application/ld+json"
|
|
48
|
+
|
|
49
|
+
MAX_ATTEMPTS = 3
|
|
50
|
+
BACKOFF_SECONDS: tuple[float, ...] = (0.5, 1.5)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Client:
|
|
54
|
+
"""Authenticated JSON calls against the platform.
|
|
55
|
+
|
|
56
|
+
:param config: where the platform is.
|
|
57
|
+
:param auth: the bearer source; see
|
|
58
|
+
:class:`~openepcis_client.core.auth.OfflineTokenAuth`.
|
|
59
|
+
:param session: a ``requests``-compatible session, injectable for tests.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
config: ClientConfig,
|
|
65
|
+
auth: AuthStrategy,
|
|
66
|
+
session: requests.Session | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._config = config
|
|
69
|
+
self._auth = auth
|
|
70
|
+
self._session = session or requests.Session()
|
|
71
|
+
|
|
72
|
+
# -- Verbs ---------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
75
|
+
return self.request("GET", path, params=params)
|
|
76
|
+
|
|
77
|
+
def put(self, path: str, payload: Any) -> Any:
|
|
78
|
+
return self.request("PUT", path, payload=payload)
|
|
79
|
+
|
|
80
|
+
def post(self, path: str, payload: Any = None) -> Any:
|
|
81
|
+
return self.request("POST", path, payload=payload)
|
|
82
|
+
|
|
83
|
+
def patch(self, path: str, payload: Any) -> Any:
|
|
84
|
+
return self.request("PATCH", path, payload=payload)
|
|
85
|
+
|
|
86
|
+
def delete(self, path: str) -> Any:
|
|
87
|
+
return self.request("DELETE", path)
|
|
88
|
+
|
|
89
|
+
def request(
|
|
90
|
+
self,
|
|
91
|
+
method: str,
|
|
92
|
+
path: str,
|
|
93
|
+
payload: Any = None,
|
|
94
|
+
params: dict[str, Any] | None = None,
|
|
95
|
+
timeout: tuple[float, float] | None = None,
|
|
96
|
+
raw: bool = False,
|
|
97
|
+
) -> Any:
|
|
98
|
+
"""Call the platform and return the decoded body.
|
|
99
|
+
|
|
100
|
+
:param raw: return the ``requests`` response instead of the parsed
|
|
101
|
+
body. For the handful of endpoints whose answer is in the headers —
|
|
102
|
+
EPCIS capture replies ``202`` with an empty body and a ``Location``
|
|
103
|
+
naming the job — where decoding the body would discard the only
|
|
104
|
+
thing the call returned.
|
|
105
|
+
:returns: the parsed JSON, or ``None`` for an empty body (``204``).
|
|
106
|
+
:raises OpenEpcisError: for every non-2xx answer and every transport
|
|
107
|
+
failure. Callers decide whether that aborts them or gets recorded.
|
|
108
|
+
"""
|
|
109
|
+
url = self._url(path)
|
|
110
|
+
method = method.upper()
|
|
111
|
+
|
|
112
|
+
tries = MAX_ATTEMPTS if method in IDEMPOTENT_METHODS else 1
|
|
113
|
+
reauth_allowed = True
|
|
114
|
+
used = 0
|
|
115
|
+
backoff: float = 0
|
|
116
|
+
|
|
117
|
+
while True:
|
|
118
|
+
if backoff:
|
|
119
|
+
time.sleep(backoff)
|
|
120
|
+
backoff = 0
|
|
121
|
+
|
|
122
|
+
headers = {
|
|
123
|
+
"Accept": ACCEPT,
|
|
124
|
+
"Authorization": f"Bearer {self._auth.bearer()}",
|
|
125
|
+
}
|
|
126
|
+
if payload is not None:
|
|
127
|
+
headers["Content-Type"] = "application/json"
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
response = self._session.request(
|
|
131
|
+
method,
|
|
132
|
+
url,
|
|
133
|
+
headers=headers,
|
|
134
|
+
params=params or None,
|
|
135
|
+
data=json.dumps(payload) if payload is not None else None,
|
|
136
|
+
timeout=timeout or self._config.request_timeout,
|
|
137
|
+
)
|
|
138
|
+
except requests.exceptions.RequestException as exc:
|
|
139
|
+
# No status: the request may or may not have been applied. Only
|
|
140
|
+
# idempotent methods get another go, so repeating is safe.
|
|
141
|
+
last_error = OpenEpcisError(
|
|
142
|
+
f"Could not reach the platform at {url}: {exc}", path=path
|
|
143
|
+
)
|
|
144
|
+
logger.warning("OpenEPCIS %s %s failed: %s", method, path, exc)
|
|
145
|
+
used += 1
|
|
146
|
+
if used >= tries:
|
|
147
|
+
raise last_error from exc
|
|
148
|
+
backoff = BACKOFF_SECONDS[min(used - 1, len(BACKOFF_SECONDS) - 1)]
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
# A 401 means the access token lapsed early or was revoked in
|
|
152
|
+
# flight. Minting a new one and going again is not a retry of the
|
|
153
|
+
# operation: the call was refused, so nothing was applied. It
|
|
154
|
+
# therefore costs no attempt and needs no backoff, which is what
|
|
155
|
+
# makes it safe even for POST.
|
|
156
|
+
if response.status_code == 401 and reauth_allowed:
|
|
157
|
+
reauth_allowed = False
|
|
158
|
+
self._auth.invalidate()
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if response.status_code < 300:
|
|
162
|
+
return response if raw else self._decode(response)
|
|
163
|
+
|
|
164
|
+
error = self._error_from(response, path)
|
|
165
|
+
used += 1
|
|
166
|
+
if error.is_retryable and used < tries:
|
|
167
|
+
logger.info(
|
|
168
|
+
"OpenEPCIS %s %s answered %s, retrying", method, path, response.status_code
|
|
169
|
+
)
|
|
170
|
+
backoff = BACKOFF_SECONDS[min(used - 1, len(BACKOFF_SECONDS) - 1)]
|
|
171
|
+
continue
|
|
172
|
+
raise error
|
|
173
|
+
|
|
174
|
+
def post_file(
|
|
175
|
+
self,
|
|
176
|
+
path: str,
|
|
177
|
+
filename: str,
|
|
178
|
+
content: bytes,
|
|
179
|
+
form: dict[str, str] | None = None,
|
|
180
|
+
timeout: tuple[float, float] | None = None,
|
|
181
|
+
) -> Any:
|
|
182
|
+
"""Upload a file as multipart form data.
|
|
183
|
+
|
|
184
|
+
Separate from :meth:`request` because the body is not JSON and must not
|
|
185
|
+
carry a ``Content-Type`` of its own; ``requests`` sets the multipart
|
|
186
|
+
boundary, and overriding it produces an unparseable request.
|
|
187
|
+
"""
|
|
188
|
+
url = self._url(path)
|
|
189
|
+
headers = {
|
|
190
|
+
"Accept": ACCEPT,
|
|
191
|
+
"Authorization": f"Bearer {self._auth.bearer()}",
|
|
192
|
+
}
|
|
193
|
+
try:
|
|
194
|
+
response = self._session.post(
|
|
195
|
+
url,
|
|
196
|
+
headers=headers,
|
|
197
|
+
files={"file": (filename, content, "application/octet-stream")},
|
|
198
|
+
data=form or {},
|
|
199
|
+
timeout=timeout or (5, 300),
|
|
200
|
+
)
|
|
201
|
+
except requests.exceptions.RequestException as exc:
|
|
202
|
+
raise OpenEpcisError(
|
|
203
|
+
f"Could not reach the platform at {url}: {exc}", path=path
|
|
204
|
+
) from exc
|
|
205
|
+
if response.status_code >= 300:
|
|
206
|
+
raise self._error_from(response, path)
|
|
207
|
+
return self._decode(response)
|
|
208
|
+
|
|
209
|
+
# -- Answers ---------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def _url(self, path: str) -> str:
|
|
212
|
+
return self._config.base_url + (path if path.startswith("/") else "/" + path)
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _decode(response: requests.Response) -> Any:
|
|
216
|
+
if response.status_code == 204 or not response.content:
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
return response.json()
|
|
220
|
+
except ValueError:
|
|
221
|
+
# A login page instead of JSON is the classic symptom of an OIDC
|
|
222
|
+
# bounce in front of the API; say so instead of "invalid JSON".
|
|
223
|
+
if "html" in (response.headers.get("Content-Type") or ""):
|
|
224
|
+
raise OpenEpcisError(
|
|
225
|
+
"The platform answered with a web page instead of data. The "
|
|
226
|
+
"URL probably points at something other than the API.",
|
|
227
|
+
status=response.status_code,
|
|
228
|
+
) from None
|
|
229
|
+
raise OpenEpcisError(
|
|
230
|
+
"The platform sent a body that is not JSON.", status=response.status_code
|
|
231
|
+
) from None
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _error_from(response: requests.Response, path: str) -> OpenEpcisError:
|
|
235
|
+
"""Turn a failed answer into a :class:`OpenEpcisError` a human can read.
|
|
236
|
+
|
|
237
|
+
The platform reports errors as RFC 7807 problem documents, so
|
|
238
|
+
``detail`` is already a sentence written for the caller. Prefer it over
|
|
239
|
+
anything this side could invent.
|
|
240
|
+
"""
|
|
241
|
+
problem: dict[str, Any] = {}
|
|
242
|
+
try:
|
|
243
|
+
body = response.json()
|
|
244
|
+
if isinstance(body, dict):
|
|
245
|
+
problem = body
|
|
246
|
+
except ValueError:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
message = str(problem.get("detail") or problem.get("title") or problem.get("message") or "")
|
|
250
|
+
if not message:
|
|
251
|
+
message = (response.text or "").strip()[:300]
|
|
252
|
+
if not message:
|
|
253
|
+
message = "The platform refused the call without saying why."
|
|
254
|
+
|
|
255
|
+
return OpenEpcisError(message, status=response.status_code, problem=problem, path=path)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 benelog GmbH & Co. KG
|
|
3
|
+
"""Connection settings, held apart from credentials.
|
|
4
|
+
|
|
5
|
+
The config names *where* the platform is; the credentials live in an
|
|
6
|
+
:class:`~openepcis_client.core.auth.AuthStrategy` and its
|
|
7
|
+
:class:`~openepcis_client.core.auth.TokenStore`, because a host framework owns
|
|
8
|
+
their persistence and this library must not.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ClientConfig:
|
|
16
|
+
"""Where the platform is and how patient to be with it.
|
|
17
|
+
|
|
18
|
+
:param base_url: origin of the GS1 Digital Link resolver, e.g.
|
|
19
|
+
``https://id.epcis.cloud``. Also the prefix of every Digital Link.
|
|
20
|
+
:param request_timeout: ``(connect, read)`` seconds for API calls.
|
|
21
|
+
:param token_timeout: ``(connect, read)`` seconds for the identity
|
|
22
|
+
provider, which answers fast or not at all.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
base_url: str
|
|
26
|
+
request_timeout: tuple[float, float] = (5, 30)
|
|
27
|
+
token_timeout: tuple[float, float] = (5, 15)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
object.__setattr__(self, "base_url", self.base_url.rstrip("/"))
|