veltro-sdk 0.15.7__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.
- veltro_sdk/__init__.py +67 -0
- veltro_sdk/_auth.py +263 -0
- veltro_sdk/_http.py +348 -0
- veltro_sdk/_operations.py +3308 -0
- veltro_sdk/client.py +245 -0
- veltro_sdk/py.typed +0 -0
- veltro_sdk-0.15.7.dist-info/METADATA +146 -0
- veltro_sdk-0.15.7.dist-info/RECORD +10 -0
- veltro_sdk-0.15.7.dist-info/WHEEL +4 -0
- veltro_sdk-0.15.7.dist-info/licenses/LICENSE +202 -0
veltro_sdk/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""``veltro-sdk`` — the Python client for the Veltro security operations suite.
|
|
3
|
+
|
|
4
|
+
Generated from the products' own OpenAPI documents (CHAD, Warden) and
|
|
5
|
+
VectorFlow's REST v1 specification; see ``scripts/generate_sdk.py`` and
|
|
6
|
+
``sdk/specs/*-operations.json`` in the ``veltrosecurity/veltro`` repository.
|
|
7
|
+
|
|
8
|
+
Quick start (suite deployment)::
|
|
9
|
+
|
|
10
|
+
from veltro_sdk import VeltroClient
|
|
11
|
+
|
|
12
|
+
suite = VeltroClient.login("https://soc.example.com", email, password,
|
|
13
|
+
vectorflow_key="vf_live_…")
|
|
14
|
+
data_sources = suite.chad.data_sources.list()
|
|
15
|
+
cases = suite.warden.cases.list(params={"limit": 25})
|
|
16
|
+
pipelines = suite.vectorflow.pipelines.list()
|
|
17
|
+
|
|
18
|
+
Quick start (one product, personal access token through the gateway)::
|
|
19
|
+
|
|
20
|
+
from veltro_sdk import BearerToken, ChadClient
|
|
21
|
+
|
|
22
|
+
chad = ChadClient("https://soc.example.com/chad", auth=BearerToken(pat))
|
|
23
|
+
chad.rules.list()
|
|
24
|
+
|
|
25
|
+
Stdlib only: no runtime dependencies.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from ._auth import Auth, BearerToken, NoAuth, SuiteSession
|
|
29
|
+
from ._http import (
|
|
30
|
+
Transport,
|
|
31
|
+
VeltroAPIError,
|
|
32
|
+
VeltroError,
|
|
33
|
+
VeltroResponseTooLarge,
|
|
34
|
+
VeltroTransportError,
|
|
35
|
+
)
|
|
36
|
+
from .client import (
|
|
37
|
+
SUITE_PREFIXES,
|
|
38
|
+
ChadClient,
|
|
39
|
+
MissingCredential,
|
|
40
|
+
VectorFlowClient,
|
|
41
|
+
VeltroClient,
|
|
42
|
+
WardenClient,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__version__ = "0.15.7"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"__version__",
|
|
49
|
+
# clients
|
|
50
|
+
"VeltroClient",
|
|
51
|
+
"ChadClient",
|
|
52
|
+
"WardenClient",
|
|
53
|
+
"VectorFlowClient",
|
|
54
|
+
"SUITE_PREFIXES",
|
|
55
|
+
# credentials
|
|
56
|
+
"Auth",
|
|
57
|
+
"NoAuth",
|
|
58
|
+
"BearerToken",
|
|
59
|
+
"SuiteSession",
|
|
60
|
+
# transport and errors
|
|
61
|
+
"Transport",
|
|
62
|
+
"VeltroError",
|
|
63
|
+
"VeltroAPIError",
|
|
64
|
+
"VeltroTransportError",
|
|
65
|
+
"VeltroResponseTooLarge",
|
|
66
|
+
"MissingCredential",
|
|
67
|
+
]
|
veltro_sdk/_auth.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Credentials the Veltro suite actually accepts, and nothing else.
|
|
3
|
+
|
|
4
|
+
Three credential shapes exist across the suite, and they are not
|
|
5
|
+
interchangeable — an SDK that guessed would fail closed in confusing ways:
|
|
6
|
+
|
|
7
|
+
:class:`BearerToken`
|
|
8
|
+
``Authorization: Bearer …``. A Veltro personal access token (``vmcp_…``)
|
|
9
|
+
for any product through the suite gateway (``/chad/api/*``,
|
|
10
|
+
``/warden/api/*``, ``/vectorflow/api/*``), where identity turns it into
|
|
11
|
+
the request assertion the product verifies; VectorFlow's service-account
|
|
12
|
+
key (``vf_live_…``, environment-scoped, for ``/api/v1``); CHAD's SCIM
|
|
13
|
+
deployment token; Warden's session token.
|
|
14
|
+
:class:`SuiteSession`
|
|
15
|
+
A suite deployment's own session, minted at the identity origin with
|
|
16
|
+
``GET /api/auth/csrf`` + ``POST /api/auth/login`` and carried as the
|
|
17
|
+
session cookie. The gateway strips that cookie toward a product and
|
|
18
|
+
exchanges it for a short-lived request assertion, which is why one suite
|
|
19
|
+
login reaches all three products and why no product-local credential is
|
|
20
|
+
needed in suite mode.
|
|
21
|
+
:class:`NoAuth`
|
|
22
|
+
Explicit anonymity, for the unauthenticated probes.
|
|
23
|
+
|
|
24
|
+
Every implementation masks its material in ``repr`` and never writes it to a
|
|
25
|
+
log; :class:`SuiteSession` additionally refuses to send a password over plain
|
|
26
|
+
HTTP unless the caller opts in for a loopback deployment.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import http.cookiejar
|
|
32
|
+
import json as _json
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.parse
|
|
35
|
+
import urllib.request
|
|
36
|
+
from typing import MutableMapping
|
|
37
|
+
|
|
38
|
+
from ._http import (
|
|
39
|
+
USER_AGENT,
|
|
40
|
+
VeltroAPIError,
|
|
41
|
+
VeltroTransportError,
|
|
42
|
+
_NoRedirect,
|
|
43
|
+
_read_capped,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"Auth",
|
|
48
|
+
"NoAuth",
|
|
49
|
+
"BearerToken",
|
|
50
|
+
"SuiteSession",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
SESSION_COOKIE_PREFIX = "authjs.session-token"
|
|
54
|
+
LOGIN_MAX_RESPONSE_BYTES = 1024 * 1024
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Auth:
|
|
58
|
+
"""Applies one credential to an outgoing request."""
|
|
59
|
+
|
|
60
|
+
def apply(
|
|
61
|
+
self, method: str, url: str, headers: MutableMapping[str, str]
|
|
62
|
+
) -> None: # pragma: no cover - interface
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NoAuth(Auth):
|
|
67
|
+
"""Send no credential."""
|
|
68
|
+
|
|
69
|
+
def apply(
|
|
70
|
+
self, method: str, url: str, headers: MutableMapping[str, str]
|
|
71
|
+
) -> None:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def __repr__(self) -> str:
|
|
75
|
+
return "NoAuth()"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BearerToken(Auth):
|
|
79
|
+
"""``Authorization: Bearer <token>``."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, token: str) -> None:
|
|
82
|
+
if not token or not token.strip():
|
|
83
|
+
raise ValueError("token is required")
|
|
84
|
+
self._token = token.strip()
|
|
85
|
+
|
|
86
|
+
def apply(
|
|
87
|
+
self, method: str, url: str, headers: MutableMapping[str, str]
|
|
88
|
+
) -> None:
|
|
89
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
90
|
+
|
|
91
|
+
def __repr__(self) -> str:
|
|
92
|
+
return f"BearerToken({_mask(self._token)})"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SuiteSession(Auth):
|
|
96
|
+
"""A suite session held as the identity-minted cookie.
|
|
97
|
+
|
|
98
|
+
``identity_url`` is the suite origin (the identity authority owns the
|
|
99
|
+
origin root). The login is performed once, lazily, and re-performed if a
|
|
100
|
+
product answers 401 and :meth:`refresh` is called by the caller.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
identity_url: str,
|
|
106
|
+
email: str,
|
|
107
|
+
password: str,
|
|
108
|
+
*,
|
|
109
|
+
timeout: float = 30.0,
|
|
110
|
+
allow_insecure_transport: bool = False,
|
|
111
|
+
) -> None:
|
|
112
|
+
if not identity_url:
|
|
113
|
+
raise ValueError("identity_url is required")
|
|
114
|
+
parsed = urllib.parse.urlsplit(identity_url)
|
|
115
|
+
if parsed.scheme not in ("http", "https"):
|
|
116
|
+
raise ValueError("identity_url must be http(s)")
|
|
117
|
+
if parsed.scheme == "http" and not allow_insecure_transport:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"refusing to send suite credentials over plain HTTP; pass "
|
|
120
|
+
"allow_insecure_transport=True only for a loopback deployment"
|
|
121
|
+
)
|
|
122
|
+
self.identity_url = identity_url.rstrip("/")
|
|
123
|
+
self._email = email
|
|
124
|
+
self._password = password
|
|
125
|
+
self._timeout = timeout
|
|
126
|
+
self._jar = http.cookiejar.CookieJar()
|
|
127
|
+
self._opener = urllib.request.build_opener(
|
|
128
|
+
_NoRedirect(), urllib.request.HTTPCookieProcessor(self._jar)
|
|
129
|
+
)
|
|
130
|
+
self._logged_in = False
|
|
131
|
+
|
|
132
|
+
def __repr__(self) -> str:
|
|
133
|
+
return (
|
|
134
|
+
f"SuiteSession(identity_url={self.identity_url!r}, "
|
|
135
|
+
f"email={self._email!r}, password=***, "
|
|
136
|
+
f"logged_in={self._logged_in})"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# -- credential application -------------------------------------------
|
|
140
|
+
def apply(
|
|
141
|
+
self, method: str, url: str, headers: MutableMapping[str, str]
|
|
142
|
+
) -> None:
|
|
143
|
+
if not self._logged_in:
|
|
144
|
+
self.login()
|
|
145
|
+
cookie = self._cookie_header(url)
|
|
146
|
+
if cookie:
|
|
147
|
+
headers["Cookie"] = cookie
|
|
148
|
+
|
|
149
|
+
def _cookie_header(self, url: str) -> str:
|
|
150
|
+
request = urllib.request.Request(url)
|
|
151
|
+
self._jar.add_cookie_header(request)
|
|
152
|
+
return request.get_header("Cookie", "")
|
|
153
|
+
|
|
154
|
+
# -- login -------------------------------------------------------------
|
|
155
|
+
def login(self) -> None:
|
|
156
|
+
"""Mint a suite session. Raises :class:`VeltroAPIError` on refusal."""
|
|
157
|
+
csrf = self._csrf()
|
|
158
|
+
body = _json.dumps({"email": self._email, "password": self._password})
|
|
159
|
+
payload = self._call(
|
|
160
|
+
"POST",
|
|
161
|
+
"/api/auth/login",
|
|
162
|
+
body.encode("utf-8"),
|
|
163
|
+
{
|
|
164
|
+
"Content-Type": "application/json",
|
|
165
|
+
"x-csrf-token": csrf,
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
if not self._session_cookie_present():
|
|
169
|
+
raise VeltroAPIError(
|
|
170
|
+
200,
|
|
171
|
+
"POST",
|
|
172
|
+
f"{self.identity_url}/api/auth/login",
|
|
173
|
+
code="suite_login_no_session_cookie",
|
|
174
|
+
message=(
|
|
175
|
+
"the identity authority accepted the login but set no "
|
|
176
|
+
"session cookie"
|
|
177
|
+
),
|
|
178
|
+
body=payload,
|
|
179
|
+
)
|
|
180
|
+
self._logged_in = True
|
|
181
|
+
|
|
182
|
+
def refresh(self) -> None:
|
|
183
|
+
"""Drop the current session and log in again."""
|
|
184
|
+
self._jar.clear()
|
|
185
|
+
self._logged_in = False
|
|
186
|
+
self.login()
|
|
187
|
+
|
|
188
|
+
def _session_cookie_present(self) -> bool:
|
|
189
|
+
return any(
|
|
190
|
+
cookie.name.startswith(SESSION_COOKIE_PREFIX) for cookie in self._jar
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _csrf(self) -> str:
|
|
194
|
+
payload = self._call("GET", "/api/auth/csrf", None, {})
|
|
195
|
+
token = ""
|
|
196
|
+
if isinstance(payload, dict):
|
|
197
|
+
token = str(payload.get("csrfToken") or payload.get("csrf_token") or "")
|
|
198
|
+
if not token:
|
|
199
|
+
raise VeltroAPIError(
|
|
200
|
+
200,
|
|
201
|
+
"GET",
|
|
202
|
+
f"{self.identity_url}/api/auth/csrf",
|
|
203
|
+
code="suite_login_no_csrf_token",
|
|
204
|
+
message="the identity authority returned no CSRF token",
|
|
205
|
+
body=payload,
|
|
206
|
+
)
|
|
207
|
+
return token
|
|
208
|
+
|
|
209
|
+
def _call(
|
|
210
|
+
self,
|
|
211
|
+
method: str,
|
|
212
|
+
path: str,
|
|
213
|
+
body: bytes | None,
|
|
214
|
+
headers: dict[str, str],
|
|
215
|
+
):
|
|
216
|
+
url = f"{self.identity_url}{path}"
|
|
217
|
+
request = urllib.request.Request(
|
|
218
|
+
url,
|
|
219
|
+
data=body,
|
|
220
|
+
method=method,
|
|
221
|
+
headers={"Accept": "application/json", "User-Agent": USER_AGENT, **headers},
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
with self._opener.open(request, timeout=self._timeout) as response:
|
|
225
|
+
raw = _read_capped(
|
|
226
|
+
response, LOGIN_MAX_RESPONSE_BYTES, method, url
|
|
227
|
+
)
|
|
228
|
+
except urllib.error.HTTPError as exc:
|
|
229
|
+
try:
|
|
230
|
+
detail = _read_capped(
|
|
231
|
+
exc, LOGIN_MAX_RESPONSE_BYTES, method, url
|
|
232
|
+
)
|
|
233
|
+
message = ""
|
|
234
|
+
try:
|
|
235
|
+
decoded = _json.loads(detail.decode("utf-8"))
|
|
236
|
+
if isinstance(decoded, dict):
|
|
237
|
+
message = str(decoded.get("error") or decoded.get("detail") or "")
|
|
238
|
+
except (UnicodeDecodeError, ValueError):
|
|
239
|
+
message = ""
|
|
240
|
+
raise VeltroAPIError(
|
|
241
|
+
exc.code,
|
|
242
|
+
method,
|
|
243
|
+
url,
|
|
244
|
+
code="suite_login_refused",
|
|
245
|
+
message=message,
|
|
246
|
+
) from None
|
|
247
|
+
finally:
|
|
248
|
+
exc.close()
|
|
249
|
+
except urllib.error.URLError as exc:
|
|
250
|
+
raise VeltroTransportError(f"{method} {url}: {exc.reason}") from None
|
|
251
|
+
if not raw:
|
|
252
|
+
return None
|
|
253
|
+
try:
|
|
254
|
+
return _json.loads(raw.decode("utf-8"))
|
|
255
|
+
except (UnicodeDecodeError, ValueError):
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _mask(secret: str) -> str:
|
|
260
|
+
"""Show enough to identify a credential, never enough to use it."""
|
|
261
|
+
if len(secret) <= 8:
|
|
262
|
+
return "'***'"
|
|
263
|
+
return f"'{secret[:4]}…***' (len {len(secret)})"
|
veltro_sdk/_http.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""HTTP transport for the Veltro SDK: stdlib only, fail-closed, capped.
|
|
3
|
+
|
|
4
|
+
Design constraints, each of them load-bearing:
|
|
5
|
+
|
|
6
|
+
* **No dependencies.** An SDK that pulls a request stack is an SDK an operator
|
|
7
|
+
cannot drop into a hardened jump host. ``urllib.request`` is enough.
|
|
8
|
+
* **Never log a credential.** Authorisation material lives behind
|
|
9
|
+
:class:`veltro_sdk.Auth` implementations whose ``repr`` masks the value, and
|
|
10
|
+
the transport never echoes request headers into an exception message.
|
|
11
|
+
* **Cap the response.** A client that reads an unbounded body is a client one
|
|
12
|
+
hostile or broken deployment can OOM. ``max_response_bytes`` is enforced
|
|
13
|
+
while streaming, not after.
|
|
14
|
+
* **Typed refusals.** Every non-2xx becomes a :class:`VeltroAPIError` carrying
|
|
15
|
+
the status, the product's own error code when it sends one, and the request
|
|
16
|
+
line — so a caller can branch on it instead of matching prose.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json as _json
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.parse
|
|
25
|
+
import urllib.request
|
|
26
|
+
from typing import Any, Iterable, Mapping
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"VeltroError",
|
|
30
|
+
"VeltroAPIError",
|
|
31
|
+
"VeltroTransportError",
|
|
32
|
+
"VeltroResponseTooLarge",
|
|
33
|
+
"Transport",
|
|
34
|
+
"Resource",
|
|
35
|
+
"quote_segment",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
DEFAULT_TIMEOUT = 30.0
|
|
39
|
+
DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024
|
|
40
|
+
DEFAULT_RETRIES = 2
|
|
41
|
+
RETRY_STATUSES = frozenset({429, 502, 503, 504})
|
|
42
|
+
RETRY_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
|
|
43
|
+
USER_AGENT = "veltro-sdk/0.15.7 (+https://github.com/veltrosecurity/veltro)"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class VeltroError(Exception):
|
|
47
|
+
"""Base class for every SDK failure."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VeltroTransportError(VeltroError):
|
|
51
|
+
"""The request never produced an HTTP response (DNS, TLS, connect, timeout)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class VeltroResponseTooLarge(VeltroError):
|
|
55
|
+
"""The response body exceeded ``max_response_bytes``."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class VeltroAPIError(VeltroError):
|
|
59
|
+
"""A product answered with a non-2xx status.
|
|
60
|
+
|
|
61
|
+
``code`` is the product's own machine-readable refusal name when it sends
|
|
62
|
+
one (CHAD and Warden answer ``{"detail": ...}``; VectorFlow answers
|
|
63
|
+
``{"error": {"code": ...}}``), otherwise ``None``.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
status: int,
|
|
69
|
+
method: str,
|
|
70
|
+
url: str,
|
|
71
|
+
*,
|
|
72
|
+
code: str | None = None,
|
|
73
|
+
message: str = "",
|
|
74
|
+
body: Any = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.status = status
|
|
77
|
+
self.method = method
|
|
78
|
+
self.url = url
|
|
79
|
+
self.code = code
|
|
80
|
+
self.message = message
|
|
81
|
+
self.body = body
|
|
82
|
+
detail = f" {code}" if code else ""
|
|
83
|
+
text = f": {message}" if message else ""
|
|
84
|
+
super().__init__(f"{method} {url} -> HTTP {status}{detail}{text}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def quote_segment(value: object) -> str:
|
|
88
|
+
"""Percent-encode one path segment.
|
|
89
|
+
|
|
90
|
+
Path parameters are interpolated into a URL, so an unencoded ``/`` or ``?``
|
|
91
|
+
in an identifier would silently retarget the request at another endpoint.
|
|
92
|
+
"""
|
|
93
|
+
return urllib.parse.quote(str(value), safe="")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _decode(body: bytes, content_type: str) -> Any:
|
|
97
|
+
if not body:
|
|
98
|
+
return None
|
|
99
|
+
if "json" in content_type:
|
|
100
|
+
try:
|
|
101
|
+
return _json.loads(body.decode("utf-8"))
|
|
102
|
+
except (UnicodeDecodeError, ValueError):
|
|
103
|
+
return body
|
|
104
|
+
if content_type.startswith("text/"):
|
|
105
|
+
try:
|
|
106
|
+
return body.decode("utf-8")
|
|
107
|
+
except UnicodeDecodeError:
|
|
108
|
+
return body
|
|
109
|
+
return body
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _error_fields(payload: Any) -> tuple[str | None, str]:
|
|
113
|
+
"""Pull (code, message) out of the three refusal shapes the suite emits."""
|
|
114
|
+
if isinstance(payload, Mapping):
|
|
115
|
+
error = payload.get("error")
|
|
116
|
+
if isinstance(error, Mapping):
|
|
117
|
+
code = error.get("code")
|
|
118
|
+
message = error.get("message") or ""
|
|
119
|
+
return (str(code) if code else None, str(message))
|
|
120
|
+
detail = payload.get("detail")
|
|
121
|
+
if isinstance(detail, Mapping):
|
|
122
|
+
code = detail.get("code") or detail.get("error")
|
|
123
|
+
message = detail.get("message") or detail.get("detail") or ""
|
|
124
|
+
return (str(code) if code else None, str(message))
|
|
125
|
+
if isinstance(detail, str):
|
|
126
|
+
return (None, detail)
|
|
127
|
+
if isinstance(detail, list):
|
|
128
|
+
return (None, _json.dumps(detail)[:500])
|
|
129
|
+
if isinstance(error, str):
|
|
130
|
+
return (None, error)
|
|
131
|
+
message = payload.get("message")
|
|
132
|
+
if isinstance(message, str):
|
|
133
|
+
return (None, message)
|
|
134
|
+
if isinstance(payload, str):
|
|
135
|
+
return (None, payload[:500])
|
|
136
|
+
return (None, "")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_capped(response, limit: int, method: str, url: str) -> bytes:
|
|
140
|
+
chunks: list[bytes] = []
|
|
141
|
+
read = 0
|
|
142
|
+
while True:
|
|
143
|
+
chunk = response.read(min(65536, limit - read + 1))
|
|
144
|
+
if not chunk:
|
|
145
|
+
break
|
|
146
|
+
read += len(chunk)
|
|
147
|
+
if read > limit:
|
|
148
|
+
raise VeltroResponseTooLarge(
|
|
149
|
+
f"{method} {url} -> response exceeded max_response_bytes={limit}"
|
|
150
|
+
)
|
|
151
|
+
chunks.append(chunk)
|
|
152
|
+
return b"".join(chunks)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _can_retry(method: str, headers: Mapping[str, str]) -> bool:
|
|
156
|
+
if method.upper() in RETRY_METHODS:
|
|
157
|
+
return True
|
|
158
|
+
return any(
|
|
159
|
+
key.lower() == "idempotency-key" and bool(value.strip())
|
|
160
|
+
for key, value in headers.items()
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
165
|
+
"""Surface redirects instead of forwarding credentials to another origin."""
|
|
166
|
+
|
|
167
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Transport:
|
|
172
|
+
"""One product's HTTP surface: base URL, auth, retries, and caps.
|
|
173
|
+
|
|
174
|
+
``base_url`` is the product's API origin *including* any suite gateway
|
|
175
|
+
prefix — ``https://soc.example.com/chad`` in a suite deployment,
|
|
176
|
+
``https://chad.internal:8080`` standalone. Operation paths from the
|
|
177
|
+
generated bindings are appended to it verbatim.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
base_url: str,
|
|
183
|
+
*,
|
|
184
|
+
auth: "Auth | None" = None,
|
|
185
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
186
|
+
retries: int = DEFAULT_RETRIES,
|
|
187
|
+
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
|
|
188
|
+
headers: Mapping[str, str] | None = None,
|
|
189
|
+
opener: urllib.request.OpenerDirector | None = None,
|
|
190
|
+
sleep=time.sleep,
|
|
191
|
+
) -> None:
|
|
192
|
+
if not base_url:
|
|
193
|
+
raise ValueError("base_url is required")
|
|
194
|
+
self.base_url = base_url.rstrip("/")
|
|
195
|
+
self.auth = auth
|
|
196
|
+
self.timeout = timeout
|
|
197
|
+
self.retries = max(0, int(retries))
|
|
198
|
+
self.max_response_bytes = int(max_response_bytes)
|
|
199
|
+
self.headers = dict(headers or {})
|
|
200
|
+
# No global opener: a process-wide opener would let one client's
|
|
201
|
+
# cookie policy leak into another's.
|
|
202
|
+
# API redirects are surfaced as typed 3xx responses. Automatically
|
|
203
|
+
# following one risks forwarding Authorization or API-key headers to
|
|
204
|
+
# an attacker-controlled Location.
|
|
205
|
+
self._opener = opener or urllib.request.build_opener(_NoRedirect())
|
|
206
|
+
self._sleep = sleep
|
|
207
|
+
|
|
208
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
209
|
+
return f"Transport(base_url={self.base_url!r}, auth={self.auth!r})"
|
|
210
|
+
|
|
211
|
+
def url_for(self, path: str, params: Mapping[str, Any] | None = None) -> str:
|
|
212
|
+
if not path.startswith("/"):
|
|
213
|
+
path = "/" + path
|
|
214
|
+
url = f"{self.base_url}{path}"
|
|
215
|
+
query = _encode_params(params)
|
|
216
|
+
return f"{url}?{query}" if query else url
|
|
217
|
+
|
|
218
|
+
def request(
|
|
219
|
+
self,
|
|
220
|
+
method: str,
|
|
221
|
+
path: str,
|
|
222
|
+
*,
|
|
223
|
+
params: Mapping[str, Any] | None = None,
|
|
224
|
+
json: Any = None,
|
|
225
|
+
data: bytes | None = None,
|
|
226
|
+
headers: Mapping[str, str] | None = None,
|
|
227
|
+
timeout: float | None = None,
|
|
228
|
+
raw: bool = False,
|
|
229
|
+
) -> Any:
|
|
230
|
+
"""Issue one request and return the decoded body (or the raw bytes)."""
|
|
231
|
+
if json is not None and data is not None:
|
|
232
|
+
raise ValueError("pass either json= or data=, not both")
|
|
233
|
+
|
|
234
|
+
url = self.url_for(path, params)
|
|
235
|
+
body = data
|
|
236
|
+
request_headers = {
|
|
237
|
+
"Accept": "application/json",
|
|
238
|
+
"User-Agent": USER_AGENT,
|
|
239
|
+
**self.headers,
|
|
240
|
+
}
|
|
241
|
+
if json is not None:
|
|
242
|
+
body = _json.dumps(json).encode("utf-8")
|
|
243
|
+
request_headers["Content-Type"] = "application/json"
|
|
244
|
+
if headers:
|
|
245
|
+
request_headers.update(headers)
|
|
246
|
+
if self.auth is not None:
|
|
247
|
+
self.auth.apply(method, url, request_headers)
|
|
248
|
+
|
|
249
|
+
attempts = self.retries + 1
|
|
250
|
+
last_error: VeltroError | None = None
|
|
251
|
+
for attempt in range(attempts):
|
|
252
|
+
request = urllib.request.Request(
|
|
253
|
+
url, data=body, method=method.upper(), headers=request_headers
|
|
254
|
+
)
|
|
255
|
+
try:
|
|
256
|
+
with self._opener.open(
|
|
257
|
+
request, timeout=self.timeout if timeout is None else timeout
|
|
258
|
+
) as response:
|
|
259
|
+
payload = _read_capped(
|
|
260
|
+
response, self.max_response_bytes, method.upper(), url
|
|
261
|
+
)
|
|
262
|
+
if raw:
|
|
263
|
+
return payload
|
|
264
|
+
return _decode(
|
|
265
|
+
payload, response.headers.get("Content-Type", "") or ""
|
|
266
|
+
)
|
|
267
|
+
except urllib.error.HTTPError as exc: # noqa: PERF203 - retry loop
|
|
268
|
+
try:
|
|
269
|
+
payload = _read_capped(
|
|
270
|
+
exc, self.max_response_bytes, method.upper(), url
|
|
271
|
+
)
|
|
272
|
+
finally:
|
|
273
|
+
exc.close()
|
|
274
|
+
decoded = _decode(payload, exc.headers.get("Content-Type", "") or "")
|
|
275
|
+
code, message = _error_fields(decoded)
|
|
276
|
+
error = VeltroAPIError(
|
|
277
|
+
exc.code,
|
|
278
|
+
method.upper(),
|
|
279
|
+
url,
|
|
280
|
+
code=code,
|
|
281
|
+
message=message,
|
|
282
|
+
body=decoded,
|
|
283
|
+
)
|
|
284
|
+
if (
|
|
285
|
+
exc.code in RETRY_STATUSES
|
|
286
|
+
and attempt < attempts - 1
|
|
287
|
+
and _can_retry(method, request_headers)
|
|
288
|
+
):
|
|
289
|
+
last_error = error
|
|
290
|
+
self._sleep(_backoff(attempt, exc.headers.get("Retry-After")))
|
|
291
|
+
continue
|
|
292
|
+
raise error from None
|
|
293
|
+
except VeltroResponseTooLarge:
|
|
294
|
+
raise
|
|
295
|
+
except urllib.error.URLError as exc:
|
|
296
|
+
error = VeltroTransportError(f"{method.upper()} {url}: {exc.reason}")
|
|
297
|
+
if attempt < attempts - 1 and _can_retry(method, request_headers):
|
|
298
|
+
last_error = error
|
|
299
|
+
self._sleep(_backoff(attempt, None))
|
|
300
|
+
continue
|
|
301
|
+
raise error from None
|
|
302
|
+
assert last_error is not None # unreachable: the loop returns or raises
|
|
303
|
+
raise last_error
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _backoff(attempt: int, retry_after: str | None) -> float:
|
|
307
|
+
if retry_after:
|
|
308
|
+
try:
|
|
309
|
+
return min(30.0, max(0.0, float(retry_after)))
|
|
310
|
+
except ValueError:
|
|
311
|
+
pass
|
|
312
|
+
return min(8.0, 0.25 * (2**attempt))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _encode_params(params: Mapping[str, Any] | None) -> str:
|
|
316
|
+
if not params:
|
|
317
|
+
return ""
|
|
318
|
+
pairs: list[tuple[str, str]] = []
|
|
319
|
+
for key, value in params.items():
|
|
320
|
+
if value is None:
|
|
321
|
+
continue
|
|
322
|
+
if isinstance(value, bool):
|
|
323
|
+
pairs.append((key, "true" if value else "false"))
|
|
324
|
+
elif isinstance(value, (list, tuple, set, frozenset)):
|
|
325
|
+
pairs.extend((key, str(item)) for item in _sorted(value))
|
|
326
|
+
else:
|
|
327
|
+
pairs.append((key, str(value)))
|
|
328
|
+
return urllib.parse.urlencode(pairs)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _sorted(value: Iterable[Any]) -> list[Any]:
|
|
332
|
+
items = list(value)
|
|
333
|
+
if isinstance(value, (set, frozenset)):
|
|
334
|
+
return sorted(items, key=str)
|
|
335
|
+
return items
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class Resource:
|
|
339
|
+
"""Base class for the generated resource bindings."""
|
|
340
|
+
|
|
341
|
+
def __init__(self, transport: Transport) -> None:
|
|
342
|
+
self._transport = transport
|
|
343
|
+
|
|
344
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
345
|
+
return f"{type(self).__name__}(base_url={self._transport.base_url!r})"
|
|
346
|
+
|
|
347
|
+
def _request(self, method: str, path: str, **options: Any) -> Any:
|
|
348
|
+
return self._transport.request(method, path, **options)
|