qurl-python 0.2.1__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.
- layerv_qurl/__init__.py +161 -0
- layerv_qurl/_utils.py +1414 -0
- layerv_qurl/async_client.py +1512 -0
- layerv_qurl/client.py +1533 -0
- layerv_qurl/errors.py +152 -0
- layerv_qurl/langchain.py +159 -0
- layerv_qurl/py.typed +0 -0
- layerv_qurl/types.py +690 -0
- qurl_python-0.2.1.dist-info/METADATA +282 -0
- qurl_python-0.2.1.dist-info/RECORD +12 -0
- qurl_python-0.2.1.dist-info/WHEEL +4 -0
- qurl_python-0.2.1.dist-info/licenses/LICENSE +21 -0
layerv_qurl/_utils.py
ADDED
|
@@ -0,0 +1,1414 @@
|
|
|
1
|
+
"""Shared utilities for sync and async clients.
|
|
2
|
+
|
|
3
|
+
Underscore-prefixed helpers here (e.g. :func:`_validate_batch_create_shape`)
|
|
4
|
+
are **package-internal**, not strict module-private: they're imported
|
|
5
|
+
by both ``client.py`` and ``async_client.py`` to keep sync/async logic
|
|
6
|
+
in lockstep, but are excluded from the public ``from layerv_qurl import``
|
|
7
|
+
surface and carry no stability guarantees. Downstream consumers should
|
|
8
|
+
not import them directly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
import functools
|
|
15
|
+
import logging
|
|
16
|
+
import random
|
|
17
|
+
import re
|
|
18
|
+
import secrets
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Iterable, Mapping, Set
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from importlib.metadata import PackageNotFoundError
|
|
24
|
+
from importlib.metadata import version as _pkg_version
|
|
25
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
26
|
+
from urllib.parse import quote
|
|
27
|
+
|
|
28
|
+
from layerv_qurl.errors import (
|
|
29
|
+
AuthenticationError,
|
|
30
|
+
AuthorizationError,
|
|
31
|
+
ConflictError,
|
|
32
|
+
GoneError,
|
|
33
|
+
NotFoundError,
|
|
34
|
+
QURLError,
|
|
35
|
+
RateLimitError,
|
|
36
|
+
ServerError,
|
|
37
|
+
ValidationError,
|
|
38
|
+
)
|
|
39
|
+
from layerv_qurl.types import (
|
|
40
|
+
QURL,
|
|
41
|
+
AccessCode,
|
|
42
|
+
AccessCodeListOutput,
|
|
43
|
+
AccessGrant,
|
|
44
|
+
AccessPolicy,
|
|
45
|
+
AccessToken,
|
|
46
|
+
AgentBootstrapOutput,
|
|
47
|
+
AIAgentPolicy,
|
|
48
|
+
APIKey,
|
|
49
|
+
APIKeyListOutput,
|
|
50
|
+
BatchCreateOutput,
|
|
51
|
+
BatchItemError,
|
|
52
|
+
BatchItemResult,
|
|
53
|
+
CheckDetail,
|
|
54
|
+
CheckoutSession,
|
|
55
|
+
ConnectorInstallation,
|
|
56
|
+
ConnectorInstallationCapabilities,
|
|
57
|
+
ConnectorInstallationListOutput,
|
|
58
|
+
ConnectorInstallationStats,
|
|
59
|
+
CreateOutput,
|
|
60
|
+
CurrentPeriodUsage,
|
|
61
|
+
Customer,
|
|
62
|
+
DailyUsage,
|
|
63
|
+
DNSRecord,
|
|
64
|
+
Domain,
|
|
65
|
+
DomainListOutput,
|
|
66
|
+
DomainVerifyOutput,
|
|
67
|
+
Invoice,
|
|
68
|
+
InvoiceListOutput,
|
|
69
|
+
ListOutput,
|
|
70
|
+
MintOutput,
|
|
71
|
+
NHPServerPeerInfo,
|
|
72
|
+
PortalSession,
|
|
73
|
+
Quota,
|
|
74
|
+
RateLimits,
|
|
75
|
+
RedeemAccessCodeOutput,
|
|
76
|
+
ResolveOutput,
|
|
77
|
+
Resource,
|
|
78
|
+
ResourceDetail,
|
|
79
|
+
ResourceListOutput,
|
|
80
|
+
Session,
|
|
81
|
+
SessionListOutput,
|
|
82
|
+
SessionTerminateOutput,
|
|
83
|
+
Usage,
|
|
84
|
+
UsageCostEstimate,
|
|
85
|
+
UsageDailyEntry,
|
|
86
|
+
Webhook,
|
|
87
|
+
WebhookDelivery,
|
|
88
|
+
WebhookDeliveryListOutput,
|
|
89
|
+
WebhookEventTypeInfo,
|
|
90
|
+
WebhookEventTypesOutput,
|
|
91
|
+
WebhookListOutput,
|
|
92
|
+
_parse_dt,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if TYPE_CHECKING:
|
|
96
|
+
from collections.abc import Callable
|
|
97
|
+
|
|
98
|
+
import httpx
|
|
99
|
+
|
|
100
|
+
logger = logging.getLogger("layerv_qurl")
|
|
101
|
+
_T = TypeVar("_T")
|
|
102
|
+
|
|
103
|
+
DEFAULT_BASE_URL = "https://api.layerv.ai"
|
|
104
|
+
DEFAULT_TIMEOUT = 30.0
|
|
105
|
+
DEFAULT_MAX_RETRIES = 3
|
|
106
|
+
RETRYABLE_STATUS = {429, 502, 503, 504}
|
|
107
|
+
# POST requests still only retry rate limits: resolve can consume one-time
|
|
108
|
+
# tokens after an NHP knock failure, and service errors are not cached.
|
|
109
|
+
RETRYABLE_STATUS_POST = {429}
|
|
110
|
+
# POST/PATCH, plus any future PUT endpoints from the API contract, carry a
|
|
111
|
+
# qurl-service replay key so retried writes are tied to one logical operation.
|
|
112
|
+
# DELETE keeps the wider HTTP retry set without a key because repeated deletes
|
|
113
|
+
# are safe by HTTP semantics.
|
|
114
|
+
IDEMPOTENCY_METHODS = {"POST", "PUT", "PATCH"}
|
|
115
|
+
|
|
116
|
+
_RESOURCE_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]+$")
|
|
117
|
+
|
|
118
|
+
_ERROR_CLASS_MAP: dict[int, type[QURLError]] = {
|
|
119
|
+
400: ValidationError,
|
|
120
|
+
401: AuthenticationError,
|
|
121
|
+
403: AuthorizationError,
|
|
122
|
+
404: NotFoundError,
|
|
123
|
+
409: ConflictError,
|
|
124
|
+
410: GoneError,
|
|
125
|
+
422: ValidationError,
|
|
126
|
+
429: RateLimitError,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class UnsetType:
|
|
131
|
+
"""Sentinel for fields where omitted and explicit null differ."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
UNSET = UnsetType()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@functools.lru_cache(maxsize=1)
|
|
138
|
+
def default_user_agent() -> str:
|
|
139
|
+
"""Return the default User-Agent string, caching the version lookup."""
|
|
140
|
+
try:
|
|
141
|
+
v = _pkg_version("qurl-python")
|
|
142
|
+
except PackageNotFoundError:
|
|
143
|
+
v = "dev"
|
|
144
|
+
return f"qurl-python-sdk/{v}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def validate_id(value: str, name: str = "resource_id") -> str:
|
|
148
|
+
"""Validate that an ID is non-empty and contains no path traversal characters."""
|
|
149
|
+
if not value or not _RESOURCE_ID_RE.match(value):
|
|
150
|
+
raise ValueError(f"Invalid {name}: {value!r}")
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _serialize_value(v: Any) -> Any:
|
|
155
|
+
"""Serialize a single value for JSON, handling dataclasses/datetimes/lists/dicts.
|
|
156
|
+
|
|
157
|
+
Unlike :func:`build_body` (which strips top-level ``None`` values so the
|
|
158
|
+
request body only carries fields the caller explicitly set), this
|
|
159
|
+
recursive helper preserves ``None`` values inside lists and nested
|
|
160
|
+
dicts. This matters because some API fields use explicit ``null`` as a
|
|
161
|
+
signalling value (e.g. ``"access_policy": {"ai_agent_policy": null}``
|
|
162
|
+
to clear a policy). Dataclass fields still skip ``None`` because the
|
|
163
|
+
dataclass itself distinguishes "unset" from "explicitly null".
|
|
164
|
+
"""
|
|
165
|
+
if v is None:
|
|
166
|
+
return None
|
|
167
|
+
if isinstance(v, datetime):
|
|
168
|
+
return v.isoformat()
|
|
169
|
+
if dataclasses.is_dataclass(v) and not isinstance(v, type):
|
|
170
|
+
return {
|
|
171
|
+
f.name: _serialize_value(getattr(v, f.name))
|
|
172
|
+
for f in dataclasses.fields(v)
|
|
173
|
+
if getattr(v, f.name) is not None
|
|
174
|
+
}
|
|
175
|
+
if isinstance(v, list):
|
|
176
|
+
return [_serialize_value(item) for item in v]
|
|
177
|
+
if isinstance(v, dict):
|
|
178
|
+
# Preserve explicit None inside nested dicts — callers who want
|
|
179
|
+
# "drop this field" should omit it from the dict, not set it to None.
|
|
180
|
+
return {k: _serialize_value(val) for k, val in v.items()}
|
|
181
|
+
return v
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_body(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
185
|
+
"""Build a request body dict from kwargs, dropping top-level None values.
|
|
186
|
+
|
|
187
|
+
Only strips ``None`` at the top level — nested values are preserved
|
|
188
|
+
as-is by :func:`_serialize_value`. Always returns a dict (at least
|
|
189
|
+
``{}``) so POST/PATCH endpoints receive a valid JSON body. Nested
|
|
190
|
+
dataclasses are recursively serialized to dicts.
|
|
191
|
+
"""
|
|
192
|
+
body: dict[str, Any] = {}
|
|
193
|
+
for k, v in kwargs.items():
|
|
194
|
+
if v is None:
|
|
195
|
+
continue
|
|
196
|
+
body[k] = _serialize_value(v)
|
|
197
|
+
return body
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def build_string_list(value: Any, field: str) -> list[str]:
|
|
201
|
+
"""Build a non-empty list of strings without accepting strings or mappings."""
|
|
202
|
+
if isinstance(value, str):
|
|
203
|
+
raise ValueError(f"{field}: must be an iterable of strings, not a string")
|
|
204
|
+
if isinstance(value, Mapping):
|
|
205
|
+
raise ValueError(f"{field}: must be an iterable of strings, not a mapping")
|
|
206
|
+
if isinstance(value, Set):
|
|
207
|
+
raise ValueError(f"{field}: must be an ordered iterable of strings, not a set")
|
|
208
|
+
if not isinstance(value, Iterable):
|
|
209
|
+
raise ValueError(f"{field}: must be an iterable of strings")
|
|
210
|
+
items = list(value)
|
|
211
|
+
if not items:
|
|
212
|
+
raise ValueError(f"{field}: cannot be empty; pass at least one value")
|
|
213
|
+
if any(not isinstance(item, str) for item in items):
|
|
214
|
+
raise ValueError(f"{field}: all values must be strings")
|
|
215
|
+
return items
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def build_query_params(pairs: dict[str, Any]) -> dict[str, str]:
|
|
219
|
+
"""Build query params from optional values, dropping ``None`` values."""
|
|
220
|
+
return {k: _query_value(v) for k, v in pairs.items() if v is not None}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _query_value(value: Any) -> str:
|
|
224
|
+
if isinstance(value, datetime):
|
|
225
|
+
return value.isoformat()
|
|
226
|
+
if value is True:
|
|
227
|
+
return "true"
|
|
228
|
+
if value is False:
|
|
229
|
+
return "false"
|
|
230
|
+
return str(value)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def idempotency_headers(
|
|
234
|
+
idempotency_key: str | None, *, min_length: int = 1
|
|
235
|
+
) -> dict[str, str] | None:
|
|
236
|
+
"""Build the optional Idempotency-Key header."""
|
|
237
|
+
if idempotency_key is None:
|
|
238
|
+
return None
|
|
239
|
+
if len(idempotency_key) < min_length:
|
|
240
|
+
raise ValueError(
|
|
241
|
+
f"idempotency_key: must be at least {min_length} characters "
|
|
242
|
+
f"(got {len(idempotency_key)})"
|
|
243
|
+
)
|
|
244
|
+
if len(idempotency_key) > 256:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"idempotency_key: must be 256 characters or fewer (got {len(idempotency_key)})"
|
|
247
|
+
)
|
|
248
|
+
if any(ord(ch) < 32 or ord(ch) == 127 for ch in idempotency_key):
|
|
249
|
+
raise ValueError("idempotency_key: must not contain control characters")
|
|
250
|
+
return {"Idempotency-Key": idempotency_key}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def ensure_mutation_idempotency(method: str, headers: dict[str, str]) -> None:
|
|
254
|
+
"""Generate a per-call idempotency key for supported mutating requests."""
|
|
255
|
+
if method.upper() not in IDEMPOTENCY_METHODS:
|
|
256
|
+
return
|
|
257
|
+
if any(key.lower() == "idempotency-key" for key in headers):
|
|
258
|
+
return
|
|
259
|
+
headers["Idempotency-Key"] = _uuid7()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _uuid7() -> str:
|
|
263
|
+
"""Generate a UUIDv7 string for Python versions before stdlib uuid7.
|
|
264
|
+
|
|
265
|
+
The random fields are for uniqueness, not monotonic ordering.
|
|
266
|
+
"""
|
|
267
|
+
timestamp_ms = time.time_ns() // 1_000_000
|
|
268
|
+
rand_a = secrets.randbits(12)
|
|
269
|
+
rand_b = secrets.randbits(62)
|
|
270
|
+
value = (
|
|
271
|
+
(timestamp_ms << 80)
|
|
272
|
+
| (0x7 << 76)
|
|
273
|
+
| (rand_a << 64)
|
|
274
|
+
| (0b10 << 62)
|
|
275
|
+
| rand_b
|
|
276
|
+
)
|
|
277
|
+
return str(uuid.UUID(int=value))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _meta_page(meta: dict[str, Any] | None) -> tuple[str | None, bool]:
|
|
281
|
+
if not meta:
|
|
282
|
+
return None, False
|
|
283
|
+
return meta.get("next_cursor"), meta.get("has_more", False)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_list_items(data: Any, parser: Callable[[dict[str, Any]], _T]) -> list[_T]:
|
|
287
|
+
if not isinstance(data, list):
|
|
288
|
+
return []
|
|
289
|
+
items: list[_T] = []
|
|
290
|
+
for item in data:
|
|
291
|
+
if not isinstance(item, dict):
|
|
292
|
+
continue
|
|
293
|
+
try:
|
|
294
|
+
items.append(parser(item))
|
|
295
|
+
except KeyError as exc:
|
|
296
|
+
missing = exc.args[0] if exc.args else "<unknown>"
|
|
297
|
+
parser_name = getattr(parser, "__name__", "parser")
|
|
298
|
+
logger.debug(
|
|
299
|
+
"Skipping malformed API list item in %s: missing %r",
|
|
300
|
+
parser_name,
|
|
301
|
+
missing,
|
|
302
|
+
)
|
|
303
|
+
continue
|
|
304
|
+
return items
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def domain_path_segment(domain: str) -> str:
|
|
308
|
+
"""Encode a custom domain for use as one URL path segment."""
|
|
309
|
+
return quote(domain, safe="")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def require_nonempty_update(body: dict[str, Any], method: str, fields: str) -> None:
|
|
313
|
+
"""Raise a consistent error when an update method has no fields."""
|
|
314
|
+
if not body:
|
|
315
|
+
raise ValueError(f"{method}: at least one field ({fields}) must be provided")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---- Spec-derived input validation --------------------------------------
|
|
319
|
+
# These mirror the constraints documented on each request schema in
|
|
320
|
+
# qurl/api/openapi.yaml so obvious mistakes fail fast with a ValueError
|
|
321
|
+
# instead of round-tripping to the API and coming back as a generic 400.
|
|
322
|
+
|
|
323
|
+
MAX_TARGET_URL = 2048
|
|
324
|
+
MAX_LABEL = 500
|
|
325
|
+
MAX_DESCRIPTION = 500
|
|
326
|
+
MAX_CUSTOM_DOMAIN = 253
|
|
327
|
+
MAX_MAX_SESSIONS = 1000
|
|
328
|
+
MAX_TAGS = 10
|
|
329
|
+
MAX_TAG_LENGTH = 50
|
|
330
|
+
# Tag item pattern mirrored from the OpenAPI spec schema
|
|
331
|
+
# `UpdateQurlRequest.tags.items.pattern` in qurl/api/openapi.yaml.
|
|
332
|
+
# Keep in lockstep with the spec — if the server pattern ever relaxes
|
|
333
|
+
# (e.g. allowing colons) the SDK must widen this regex or it will
|
|
334
|
+
# reject strings the API would otherwise accept.
|
|
335
|
+
_TAG_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9 _-]*$")
|
|
336
|
+
_ALIAS_PATTERN = re.compile(r"^[a-z][a-z0-9-]{1,62}[a-z0-9]$")
|
|
337
|
+
_JWT_LIKE_PATTERN = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
|
|
338
|
+
_RESERVED_ALIASES = frozenset(
|
|
339
|
+
{
|
|
340
|
+
"setalias",
|
|
341
|
+
"unsetalias",
|
|
342
|
+
"get",
|
|
343
|
+
"aliases",
|
|
344
|
+
"admin",
|
|
345
|
+
"claim",
|
|
346
|
+
"register",
|
|
347
|
+
"agent",
|
|
348
|
+
"knock",
|
|
349
|
+
"token",
|
|
350
|
+
"bootstrap",
|
|
351
|
+
"status",
|
|
352
|
+
"info",
|
|
353
|
+
"whoami",
|
|
354
|
+
"revoke",
|
|
355
|
+
"delete",
|
|
356
|
+
"enable",
|
|
357
|
+
"disable",
|
|
358
|
+
"audit",
|
|
359
|
+
"version",
|
|
360
|
+
"health",
|
|
361
|
+
"create",
|
|
362
|
+
"new",
|
|
363
|
+
"list",
|
|
364
|
+
"help",
|
|
365
|
+
"all",
|
|
366
|
+
"frpc",
|
|
367
|
+
"frps",
|
|
368
|
+
"tunnel",
|
|
369
|
+
"qurl",
|
|
370
|
+
"me",
|
|
371
|
+
"*",
|
|
372
|
+
}
|
|
373
|
+
)
|
|
374
|
+
RESOURCE_ID_PREFIX = "r_"
|
|
375
|
+
# target_url must use an http(s) scheme per the API's SSRF protection.
|
|
376
|
+
# This is a cheap client-side sanity check — the server is still the
|
|
377
|
+
# authoritative validator (e.g. it rejects localhost, cloud metadata,
|
|
378
|
+
# and private-range hosts; the SDK doesn't need to duplicate that).
|
|
379
|
+
_ALLOWED_URL_SCHEMES = ("http://", "https://")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _require_max_length(value: str | None, field_name: str, maximum: int) -> None:
|
|
383
|
+
if value is not None and len(value) > maximum:
|
|
384
|
+
raise ValueError(
|
|
385
|
+
f"{field_name}: must be {maximum} characters or fewer (got {len(value)})"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def validate_required_string(value: str, field_name: str) -> None:
|
|
390
|
+
"""Validate required string request fields that have no richer local schema."""
|
|
391
|
+
if not isinstance(value, str) or not value.strip():
|
|
392
|
+
raise ValueError(f"{field_name}: must be a non-empty string")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def validate_alias(value: str | None, field_name: str = "alias") -> None:
|
|
396
|
+
"""Validate resource alias strings against the qurl-service contract."""
|
|
397
|
+
if value is None:
|
|
398
|
+
return
|
|
399
|
+
if not isinstance(value, str) or not _ALIAS_PATTERN.match(value):
|
|
400
|
+
raise ValueError(
|
|
401
|
+
f"{field_name}: must be 3-64 lowercase alphanumeric characters or "
|
|
402
|
+
"hyphens, start with a letter, and end alphanumeric"
|
|
403
|
+
)
|
|
404
|
+
if value in _RESERVED_ALIASES:
|
|
405
|
+
raise ValueError(f"{field_name}: reserved alias")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def validate_nonnegative_int(value: int, field_name: str) -> None:
|
|
409
|
+
"""Validate required non-negative integer request fields."""
|
|
410
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
411
|
+
raise ValueError(f"{field_name}: must be a non-negative integer")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _require_http_url(value: str, field_name: str) -> None:
|
|
415
|
+
if not isinstance(value, str) or not value.startswith(_ALLOWED_URL_SCHEMES):
|
|
416
|
+
raise ValueError(
|
|
417
|
+
f"{field_name}: must start with http:// or https:// (got {repr(value)[:40]})"
|
|
418
|
+
)
|
|
419
|
+
_require_max_length(value, field_name, MAX_TARGET_URL)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _require_max_sessions_in_range(value: int | None) -> None:
|
|
423
|
+
if value is None:
|
|
424
|
+
return
|
|
425
|
+
# `bool` is a subclass of `int` in Python (True == 1, False == 0), so
|
|
426
|
+
# a caller passing `max_sessions=True` would sneak through an
|
|
427
|
+
# `isinstance(value, int)` check alone. Reject bool explicitly so
|
|
428
|
+
# obvious type confusion fails loudly instead of silently meaning 1.
|
|
429
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
430
|
+
raise ValueError(f"max_sessions: must be an integer (got {type(value).__name__})")
|
|
431
|
+
if value < 0 or value > MAX_MAX_SESSIONS:
|
|
432
|
+
raise ValueError(
|
|
433
|
+
f"max_sessions: must be an integer between 0 and {MAX_MAX_SESSIONS} (got {value})"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _require_valid_tags(tags: list[str] | None) -> None:
|
|
438
|
+
if tags is None:
|
|
439
|
+
return
|
|
440
|
+
if len(tags) > MAX_TAGS:
|
|
441
|
+
raise ValueError(f"tags: max {MAX_TAGS} items allowed (got {len(tags)})")
|
|
442
|
+
for tag in tags:
|
|
443
|
+
if not isinstance(tag, str) or len(tag) < 1 or len(tag) > MAX_TAG_LENGTH:
|
|
444
|
+
raise ValueError(
|
|
445
|
+
f"tags: each tag must be 1-{MAX_TAG_LENGTH} characters "
|
|
446
|
+
f"(got {len(tag) if isinstance(tag, str) else type(tag).__name__})"
|
|
447
|
+
)
|
|
448
|
+
if not _TAG_PATTERN.match(tag):
|
|
449
|
+
raise ValueError(
|
|
450
|
+
"tags: each tag must start with an alphanumeric and contain only "
|
|
451
|
+
"letters, numbers, spaces, underscores, or hyphens"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def validate_create_input(
|
|
456
|
+
*,
|
|
457
|
+
target_url: str,
|
|
458
|
+
label: str | None = None,
|
|
459
|
+
max_sessions: int | None = None,
|
|
460
|
+
custom_domain: str | None = None,
|
|
461
|
+
) -> None:
|
|
462
|
+
"""Validate a single create_qurl input against spec-documented constraints.
|
|
463
|
+
|
|
464
|
+
Used by both ``create()`` and ``batch_create()`` (for each item).
|
|
465
|
+
Raises ``ValueError`` on constraint violations so obvious mistakes
|
|
466
|
+
fail fast instead of round-tripping to the API.
|
|
467
|
+
"""
|
|
468
|
+
# `repr(...)[:40]` inside `_require_http_url` avoids the old
|
|
469
|
+
# `target_url[:32]!r` trap for non-subscriptable inputs (None, int,
|
|
470
|
+
# bool, ...), surfacing a clean ValueError instead of TypeError.
|
|
471
|
+
_require_http_url(target_url, "target_url")
|
|
472
|
+
_require_max_length(label, "label", MAX_LABEL)
|
|
473
|
+
_require_max_length(custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN)
|
|
474
|
+
_require_max_sessions_in_range(max_sessions)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def validate_update_input(
|
|
478
|
+
*,
|
|
479
|
+
description: str | None = None,
|
|
480
|
+
tags: list[str] | None = None,
|
|
481
|
+
custom_domain: str | None = None,
|
|
482
|
+
) -> None:
|
|
483
|
+
"""Validate update_qurl input against spec-documented constraints."""
|
|
484
|
+
_require_max_length(description, "description", MAX_DESCRIPTION)
|
|
485
|
+
_require_max_length(custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN)
|
|
486
|
+
_require_valid_tags(tags)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def validate_domain_input(domain: str) -> None:
|
|
490
|
+
"""Validate custom-domain strings before using them in URL path segments."""
|
|
491
|
+
validate_required_string(domain, "domain")
|
|
492
|
+
_require_max_length(domain, "domain", MAX_CUSTOM_DOMAIN)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def validate_webhook_url(url: str) -> None:
|
|
496
|
+
"""Validate webhook callback URLs with the same basic HTTP(S) guard."""
|
|
497
|
+
_require_http_url(url, "url")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def validate_mint_input(
|
|
501
|
+
*,
|
|
502
|
+
label: str | None = None,
|
|
503
|
+
max_sessions: int | None = None,
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Validate mint_link input against spec-documented constraints."""
|
|
506
|
+
_require_max_length(label, "label", MAX_LABEL)
|
|
507
|
+
_require_max_sessions_in_range(max_sessions)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def require_resource_id_prefix(resource_id: str, operation: str = "delete") -> None:
|
|
511
|
+
"""Enforce the ``r_`` prefix on endpoints that only accept resource IDs.
|
|
512
|
+
|
|
513
|
+
Per the OpenAPI spec, ``DELETE /v1/qurls/:id`` explicitly requires a
|
|
514
|
+
resource ID (r_ prefix) — the token-scoped endpoint is
|
|
515
|
+
``DELETE /v1/resources/:id/qurls/:qurl_id``. Catch the common mistake
|
|
516
|
+
of passing a ``q_`` display ID here with a clear client-side error.
|
|
517
|
+
"""
|
|
518
|
+
if not resource_id.startswith(RESOURCE_ID_PREFIX):
|
|
519
|
+
# Don't echo the raw ID — even truncated to 16 chars it may
|
|
520
|
+
# contain caller-sensitive data that ends up in error logs.
|
|
521
|
+
# Echo only the 2-char prefix so the caller sees which kind of
|
|
522
|
+
# ID they passed without leaking the value.
|
|
523
|
+
observed_prefix = resource_id[:2]
|
|
524
|
+
# TODO: update the "not yet available in this SDK version"
|
|
525
|
+
# wording once a token-scoped `revoke_token()` / equivalent
|
|
526
|
+
# method lands on the client. Until then, the error points
|
|
527
|
+
# users at the API-level endpoint without a concrete SDK call.
|
|
528
|
+
raise ValueError(
|
|
529
|
+
f"{operation}: only resource IDs ({RESOURCE_ID_PREFIX} prefix) are accepted — "
|
|
530
|
+
f"got an ID starting with {observed_prefix!r}. To revoke a single "
|
|
531
|
+
"access token, use the token-scoped revoke endpoint (not yet "
|
|
532
|
+
"available in this SDK version)."
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _parse_access_policy(data: dict[str, Any] | None) -> AccessPolicy | None:
|
|
537
|
+
"""Parse an AccessPolicy from API response data."""
|
|
538
|
+
if data is None:
|
|
539
|
+
return None
|
|
540
|
+
ai_policy = None
|
|
541
|
+
ap = data.get("ai_agent_policy")
|
|
542
|
+
# Guard against non-dict values (e.g. API returning a bare string
|
|
543
|
+
# or boolean for ai_agent_policy). Without this, `.get("block_all")`
|
|
544
|
+
# would raise AttributeError. Consistent with the defensive posture
|
|
545
|
+
# in `_validate_batch_create_shape`.
|
|
546
|
+
if isinstance(ap, dict):
|
|
547
|
+
ai_policy = AIAgentPolicy(
|
|
548
|
+
block_all=ap.get("block_all"),
|
|
549
|
+
deny_categories=ap.get("deny_categories"),
|
|
550
|
+
allow_categories=ap.get("allow_categories"),
|
|
551
|
+
)
|
|
552
|
+
return AccessPolicy(
|
|
553
|
+
ip_allowlist=data.get("ip_allowlist"),
|
|
554
|
+
ip_denylist=data.get("ip_denylist"),
|
|
555
|
+
geo_allowlist=data.get("geo_allowlist"),
|
|
556
|
+
geo_denylist=data.get("geo_denylist"),
|
|
557
|
+
user_agent_allow_regex=data.get("user_agent_allow_regex"),
|
|
558
|
+
user_agent_deny_regex=data.get("user_agent_deny_regex"),
|
|
559
|
+
ai_agent_policy=ai_policy,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def parse_access_token(data: dict[str, Any]) -> AccessToken:
|
|
564
|
+
"""Parse a qURL token summary."""
|
|
565
|
+
return AccessToken(
|
|
566
|
+
qurl_id=data["qurl_id"],
|
|
567
|
+
status=data["status"],
|
|
568
|
+
one_time_use=data.get("one_time_use", False),
|
|
569
|
+
max_sessions=data.get("max_sessions", 0),
|
|
570
|
+
session_duration=data.get("session_duration", 0),
|
|
571
|
+
use_count=data.get("use_count", 0),
|
|
572
|
+
label=data.get("label"),
|
|
573
|
+
qurl_site=data.get("qurl_site"),
|
|
574
|
+
access_policy=_parse_access_policy(data.get("access_policy")),
|
|
575
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
576
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _parse_access_token_preview_items(data: Any) -> list[AccessToken]:
|
|
581
|
+
if not isinstance(data, list):
|
|
582
|
+
return []
|
|
583
|
+
return [
|
|
584
|
+
parse_access_token(item)
|
|
585
|
+
for item in data
|
|
586
|
+
if isinstance(item, dict) and "qurl_id" in item and "status" in item
|
|
587
|
+
]
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def parse_qurl(data: dict[str, Any]) -> QURL:
|
|
591
|
+
"""Parse a qURL resource from API response data."""
|
|
592
|
+
tokens = None
|
|
593
|
+
# API returns "qurls" array; SDK exposes as "access_tokens" for clarity.
|
|
594
|
+
raw_tokens = data.get("qurls") if "qurls" in data else data.get("access_tokens")
|
|
595
|
+
if raw_tokens is not None:
|
|
596
|
+
tokens = _parse_list_items(raw_tokens, parse_access_token)
|
|
597
|
+
return QURL(
|
|
598
|
+
resource_id=data["resource_id"],
|
|
599
|
+
target_url=data.get("target_url"),
|
|
600
|
+
status=data["status"],
|
|
601
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
602
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
603
|
+
description=data.get("description"),
|
|
604
|
+
tags=data.get("tags") or [],
|
|
605
|
+
qurl_site=data.get("qurl_site"),
|
|
606
|
+
custom_domain=data.get("custom_domain"),
|
|
607
|
+
slug=data.get("slug"),
|
|
608
|
+
qurl_count=data.get("qurl_count"),
|
|
609
|
+
access_tokens=tokens,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def parse_create_output(data: dict[str, Any]) -> CreateOutput:
|
|
614
|
+
"""Parse a CreateOutput from API response data."""
|
|
615
|
+
# Normalize empty-string `qurl_id` → None for idiomatic truthiness
|
|
616
|
+
# checks. Intentionally asymmetric with `label` (preserved as-is):
|
|
617
|
+
# `""` is never a meaningful identifier but IS a meaningful "cleared"
|
|
618
|
+
# value for user-facing metadata.
|
|
619
|
+
qurl_id = data.get("qurl_id") or None
|
|
620
|
+
return CreateOutput(
|
|
621
|
+
resource_id=data["resource_id"],
|
|
622
|
+
qurl_link=data["qurl_link"],
|
|
623
|
+
qurl_site=data["qurl_site"],
|
|
624
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
625
|
+
qurl_id=qurl_id,
|
|
626
|
+
label=data.get("label"),
|
|
627
|
+
branded_domain=data.get("branded_domain"),
|
|
628
|
+
resource_type=data.get("type"),
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def parse_mint_output(data: dict[str, Any]) -> MintOutput:
|
|
633
|
+
"""Parse a MintOutput from API response data."""
|
|
634
|
+
return MintOutput(
|
|
635
|
+
qurl_link=data["qurl_link"],
|
|
636
|
+
qurl_id=data.get("qurl_id") or None,
|
|
637
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
638
|
+
branded_domain=data.get("branded_domain"),
|
|
639
|
+
resource_type=data.get("type"),
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _parse_access_grant(data: dict[str, Any] | None) -> AccessGrant | None:
|
|
644
|
+
if data is None:
|
|
645
|
+
return None
|
|
646
|
+
return AccessGrant(
|
|
647
|
+
expires_in=data["expires_in"],
|
|
648
|
+
granted_at=_parse_dt(data.get("granted_at")),
|
|
649
|
+
src_ip=data.get("src_ip", ""),
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def parse_resolve_output(data: dict[str, Any]) -> ResolveOutput:
|
|
654
|
+
"""Parse a ResolveOutput from API response data."""
|
|
655
|
+
return ResolveOutput(
|
|
656
|
+
target_url=data.get("target_url"),
|
|
657
|
+
resource_id=data["resource_id"],
|
|
658
|
+
access_grant=_parse_access_grant(data.get("access_grant")),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def parse_quota(data: dict[str, Any]) -> Quota:
|
|
663
|
+
"""Parse a Quota from API response data."""
|
|
664
|
+
return Quota(
|
|
665
|
+
# Fall back to the same sentinel the dataclass default uses
|
|
666
|
+
# (see ``Quota.plan`` in types.py) so a malformed API response
|
|
667
|
+
# that omits the field produces a consistent "not-yet-populated"
|
|
668
|
+
# value regardless of whether the Quota was constructed via
|
|
669
|
+
# parse_quota or directly. In practice the /v1/quota endpoint
|
|
670
|
+
# always returns a populated plan string, so this fallback is
|
|
671
|
+
# only hit for malformed responses or internal bootstrap paths.
|
|
672
|
+
plan=data.get("plan", "unknown"),
|
|
673
|
+
period_start=_parse_dt(data.get("period_start")),
|
|
674
|
+
period_end=_parse_dt(data.get("period_end")),
|
|
675
|
+
rate_limits=_parse_rate_limits(data.get("rate_limits")),
|
|
676
|
+
usage=_parse_usage_block(data.get("usage")),
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _parse_rate_limits(data: dict[str, Any] | None) -> RateLimits | None:
|
|
681
|
+
if data is None:
|
|
682
|
+
return None
|
|
683
|
+
return RateLimits(
|
|
684
|
+
create_per_minute=data.get("create_per_minute", 0),
|
|
685
|
+
create_per_hour=data.get("create_per_hour", 0),
|
|
686
|
+
list_per_minute=data.get("list_per_minute", 0),
|
|
687
|
+
resolve_per_minute=data.get("resolve_per_minute", 0),
|
|
688
|
+
max_active_qurls=data.get("max_active_qurls", 0),
|
|
689
|
+
max_tokens_per_qurl=data.get("max_tokens_per_qurl", 0),
|
|
690
|
+
max_expiry_seconds=data.get("max_expiry_seconds", 0),
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _parse_usage_block(data: dict[str, Any] | None) -> Usage | None:
|
|
695
|
+
if data is None:
|
|
696
|
+
return None
|
|
697
|
+
return Usage(
|
|
698
|
+
qurls_created=data.get("qurls_created", 0),
|
|
699
|
+
active_qurls=data.get("active_qurls", 0),
|
|
700
|
+
# Nullable per the API spec — the field is null when
|
|
701
|
+
# max_active_qurls is unlimited.
|
|
702
|
+
active_qurls_percent=data.get("active_qurls_percent"),
|
|
703
|
+
total_accesses=data.get("total_accesses", 0),
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def parse_list_output(data: Any, meta: dict[str, Any] | None) -> ListOutput:
|
|
708
|
+
"""Parse a ListOutput from API response data."""
|
|
709
|
+
next_cursor, has_more = _meta_page(meta)
|
|
710
|
+
qurls = _parse_list_items(data, parse_qurl)
|
|
711
|
+
return ListOutput(qurls=qurls, next_cursor=next_cursor, has_more=has_more)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _parse_resource(data: dict[str, Any], *, strict_identity: bool) -> Resource:
|
|
715
|
+
resource_id = data["resource_id"] if strict_identity else data.get("resource_id", "")
|
|
716
|
+
status = data["status"] if strict_identity else data.get("status", "unknown")
|
|
717
|
+
return Resource(
|
|
718
|
+
resource_id=resource_id,
|
|
719
|
+
status=status,
|
|
720
|
+
resource_type=data.get("type"),
|
|
721
|
+
target_url=data.get("target_url"),
|
|
722
|
+
knock_resource_id=data.get("knock_resource_id"),
|
|
723
|
+
description=data.get("description"),
|
|
724
|
+
tags=data.get("tags") or [],
|
|
725
|
+
custom_domain=data.get("custom_domain"),
|
|
726
|
+
alias=data.get("alias"),
|
|
727
|
+
slug=data.get("slug"),
|
|
728
|
+
preserve_host=data.get("preserve_host", False),
|
|
729
|
+
session_duration_cap=data.get("session_duration_cap"),
|
|
730
|
+
qurl_count=data.get("qurl_count"),
|
|
731
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
732
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
733
|
+
tombstoned_at=_parse_dt(data.get("tombstoned_at")),
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def parse_resource(data: dict[str, Any]) -> Resource:
|
|
738
|
+
"""Parse a resource-management API resource."""
|
|
739
|
+
return _parse_resource(data, strict_identity=True)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def parse_resource_detail(data: dict[str, Any]) -> ResourceDetail:
|
|
743
|
+
"""Parse a resource detail response."""
|
|
744
|
+
raw_resource = data.get("resource")
|
|
745
|
+
resource = _parse_resource(
|
|
746
|
+
raw_resource if isinstance(raw_resource, dict) else {},
|
|
747
|
+
strict_identity=False,
|
|
748
|
+
)
|
|
749
|
+
qurls = _parse_access_token_preview_items(data.get("qurls"))
|
|
750
|
+
return ResourceDetail(resource=resource, qurls=qurls)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def parse_resource_list_output(
|
|
754
|
+
data: Any, meta: dict[str, Any] | None
|
|
755
|
+
) -> ResourceListOutput:
|
|
756
|
+
"""Parse a resource list response."""
|
|
757
|
+
next_cursor, has_more = _meta_page(meta)
|
|
758
|
+
resources = _parse_list_items(data, parse_resource)
|
|
759
|
+
return ResourceListOutput(resources=resources, next_cursor=next_cursor, has_more=has_more)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def parse_session(data: dict[str, Any]) -> Session:
|
|
763
|
+
"""Parse an active resource session."""
|
|
764
|
+
return Session(
|
|
765
|
+
session_id=data["session_id"],
|
|
766
|
+
qurl_id=data.get("qurl_id"),
|
|
767
|
+
src_ip=data.get("src_ip"),
|
|
768
|
+
user_agent=data.get("user_agent"),
|
|
769
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
770
|
+
last_seen_at=_parse_dt(data.get("last_seen_at")),
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def parse_session_list_output(data: Any) -> SessionListOutput:
|
|
775
|
+
"""Parse an active session list response."""
|
|
776
|
+
sessions = _parse_list_items(data, parse_session)
|
|
777
|
+
return SessionListOutput(sessions=sessions)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def parse_session_terminate_output(data: dict[str, Any] | None) -> SessionTerminateOutput:
|
|
781
|
+
"""Parse a session termination response."""
|
|
782
|
+
return SessionTerminateOutput(terminated=(data or {}).get("terminated", 0))
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _parse_dns_record(data: dict[str, Any]) -> DNSRecord:
|
|
786
|
+
return DNSRecord(
|
|
787
|
+
type=data.get("type", ""),
|
|
788
|
+
name=data.get("name", ""),
|
|
789
|
+
value=data.get("value", ""),
|
|
790
|
+
verified=data.get("verified", False),
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
# Object identity fields stay strict so malformed rows fail at the parser,
|
|
795
|
+
# while ancillary fields use defaults for partial-payload tolerance.
|
|
796
|
+
def parse_domain(data: dict[str, Any]) -> Domain:
|
|
797
|
+
"""Parse a custom domain response."""
|
|
798
|
+
return Domain(
|
|
799
|
+
domain=data["domain"],
|
|
800
|
+
status=data["status"],
|
|
801
|
+
verification_token=data.get("verification_token"),
|
|
802
|
+
token_expires_at=_parse_dt(data.get("token_expires_at")),
|
|
803
|
+
acme_cname_target=data.get("acme_cname_target"),
|
|
804
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
805
|
+
verified_at=_parse_dt(data.get("verified_at")),
|
|
806
|
+
activated_at=_parse_dt(data.get("activated_at")),
|
|
807
|
+
ready_for_qurls=data.get("ready_for_qurls", False),
|
|
808
|
+
dns_records=[
|
|
809
|
+
_parse_dns_record(r)
|
|
810
|
+
for r in data.get("dns_records") or []
|
|
811
|
+
if isinstance(r, dict)
|
|
812
|
+
],
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def parse_domain_list_output(data: Any, meta: dict[str, Any] | None) -> DomainListOutput:
|
|
817
|
+
"""Parse a custom-domain list response."""
|
|
818
|
+
next_cursor, has_more = _meta_page(meta)
|
|
819
|
+
domains = _parse_list_items(data, parse_domain)
|
|
820
|
+
return DomainListOutput(domains=domains, next_cursor=next_cursor, has_more=has_more)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _parse_check_detail(data: dict[str, Any]) -> CheckDetail:
|
|
824
|
+
return CheckDetail(
|
|
825
|
+
verified=data.get("verified", False),
|
|
826
|
+
error=data.get("error"),
|
|
827
|
+
found=data.get("found"),
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def parse_domain_verify_output(data: dict[str, Any]) -> DomainVerifyOutput:
|
|
832
|
+
"""Parse custom-domain verification output."""
|
|
833
|
+
checks = {
|
|
834
|
+
name: _parse_check_detail(check)
|
|
835
|
+
for name, check in data.get("checks", {}).items()
|
|
836
|
+
if isinstance(check, dict)
|
|
837
|
+
}
|
|
838
|
+
return DomainVerifyOutput(
|
|
839
|
+
domain=data["domain"],
|
|
840
|
+
status=data["status"],
|
|
841
|
+
checks=checks,
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def parse_webhook(data: dict[str, Any]) -> Webhook:
|
|
846
|
+
"""Parse webhook subscription data."""
|
|
847
|
+
return Webhook(
|
|
848
|
+
webhook_id=data["webhook_id"],
|
|
849
|
+
owner_id=data.get("owner_id"),
|
|
850
|
+
url=data.get("url", ""),
|
|
851
|
+
events=data.get("events") or [],
|
|
852
|
+
status=data.get("status"),
|
|
853
|
+
description=data.get("description"),
|
|
854
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
855
|
+
updated_at=_parse_dt(data.get("updated_at")),
|
|
856
|
+
failure_count=data.get("failure_count", 0),
|
|
857
|
+
last_delivery_success=data.get("last_delivery_success"),
|
|
858
|
+
last_delivery_time=data.get("last_delivery_time"),
|
|
859
|
+
secret=data.get("secret"),
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def parse_webhook_list_output(data: Any, meta: dict[str, Any] | None) -> WebhookListOutput:
|
|
864
|
+
"""Parse a webhook list response."""
|
|
865
|
+
next_cursor, has_more = _meta_page(meta)
|
|
866
|
+
webhooks = _parse_list_items(data, parse_webhook)
|
|
867
|
+
return WebhookListOutput(webhooks=webhooks, next_cursor=next_cursor, has_more=has_more)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def parse_webhook_delivery(data: dict[str, Any]) -> WebhookDelivery:
|
|
871
|
+
"""Parse webhook delivery data."""
|
|
872
|
+
return WebhookDelivery(
|
|
873
|
+
delivery_id=data["delivery_id"],
|
|
874
|
+
webhook_id=data.get("webhook_id"),
|
|
875
|
+
event_type=data.get("event_type"),
|
|
876
|
+
status=data.get("status"),
|
|
877
|
+
response_code=data.get("response_code"),
|
|
878
|
+
response_body=data.get("response_body"),
|
|
879
|
+
error_message=data.get("error_message"),
|
|
880
|
+
duration_ms=data.get("duration_ms"),
|
|
881
|
+
retry_count=data.get("retry_count", 0),
|
|
882
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
883
|
+
completed_at=_parse_dt(data.get("completed_at")),
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def parse_webhook_delivery_list_output(
|
|
888
|
+
data: Any, meta: dict[str, Any] | None
|
|
889
|
+
) -> WebhookDeliveryListOutput:
|
|
890
|
+
"""Parse a webhook delivery list response."""
|
|
891
|
+
next_cursor, has_more = _meta_page(meta)
|
|
892
|
+
deliveries = _parse_list_items(data, parse_webhook_delivery)
|
|
893
|
+
return WebhookDeliveryListOutput(
|
|
894
|
+
deliveries=deliveries, next_cursor=next_cursor, has_more=has_more
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _parse_event_type_info(data: dict[str, Any]) -> WebhookEventTypeInfo:
|
|
899
|
+
return WebhookEventTypeInfo(
|
|
900
|
+
type=data.get("type", ""),
|
|
901
|
+
category=data.get("category"),
|
|
902
|
+
description=data.get("description"),
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def parse_webhook_event_types_output(data: Any) -> WebhookEventTypesOutput:
|
|
907
|
+
"""Parse supported webhook event types."""
|
|
908
|
+
events = [
|
|
909
|
+
event
|
|
910
|
+
for event in _parse_list_items(data, _parse_event_type_info)
|
|
911
|
+
if event.type
|
|
912
|
+
]
|
|
913
|
+
return WebhookEventTypesOutput(events=events)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def parse_api_key(data: dict[str, Any]) -> APIKey:
|
|
917
|
+
"""Parse API key metadata."""
|
|
918
|
+
return APIKey(
|
|
919
|
+
key_id=data["key_id"],
|
|
920
|
+
key_prefix=data["key_prefix"],
|
|
921
|
+
name=data.get("name", ""),
|
|
922
|
+
scopes=data.get("scopes") or [],
|
|
923
|
+
status=data.get("status"),
|
|
924
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
925
|
+
updated_at=_parse_dt(data.get("updated_at")),
|
|
926
|
+
last_used_at=_parse_dt(data.get("last_used_at")),
|
|
927
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
928
|
+
purpose=data.get("purpose"),
|
|
929
|
+
tunnel_slug=data.get("tunnel_slug"),
|
|
930
|
+
api_key=data.get("api_key"),
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def parse_api_key_list_output(data: Any, meta: dict[str, Any] | None) -> APIKeyListOutput:
|
|
935
|
+
"""Parse an API key list response."""
|
|
936
|
+
next_cursor, has_more = _meta_page(meta)
|
|
937
|
+
api_keys = _parse_list_items(data, parse_api_key)
|
|
938
|
+
return APIKeyListOutput(api_keys=api_keys, next_cursor=next_cursor, has_more=has_more)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def parse_redeem_access_code_output(data: dict[str, Any]) -> RedeemAccessCodeOutput:
|
|
942
|
+
"""Parse public access-code redemption output."""
|
|
943
|
+
return RedeemAccessCodeOutput(redirect_url=data["redirect_url"])
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def parse_access_code(data: dict[str, Any]) -> AccessCode:
|
|
947
|
+
"""Parse access-code metadata."""
|
|
948
|
+
return AccessCode(
|
|
949
|
+
access_code_id=data["access_code_id"],
|
|
950
|
+
resource_id=data["resource_id"],
|
|
951
|
+
name=data.get("name"),
|
|
952
|
+
status=data.get("status"),
|
|
953
|
+
max_uses=data.get("max_uses", 0),
|
|
954
|
+
use_count=data.get("use_count", 0),
|
|
955
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
956
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
957
|
+
code=data.get("code"),
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def parse_access_code_list_output(
|
|
962
|
+
data: Any, meta: dict[str, Any] | None
|
|
963
|
+
) -> AccessCodeListOutput:
|
|
964
|
+
"""Parse an access-code list response."""
|
|
965
|
+
next_cursor, has_more = _meta_page(meta)
|
|
966
|
+
access_codes = _parse_list_items(data, parse_access_code)
|
|
967
|
+
return AccessCodeListOutput(
|
|
968
|
+
access_codes=access_codes, next_cursor=next_cursor, has_more=has_more
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _parse_usage_cost(data: dict[str, Any] | None) -> UsageCostEstimate | None:
|
|
973
|
+
if data is None:
|
|
974
|
+
return None
|
|
975
|
+
return UsageCostEstimate(
|
|
976
|
+
currency=data.get("currency", ""),
|
|
977
|
+
amount_cents=data.get("amount_cents", 0),
|
|
978
|
+
description=data.get("description", ""),
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def parse_current_period_usage(data: dict[str, Any]) -> CurrentPeriodUsage:
|
|
983
|
+
"""Parse current-period usage."""
|
|
984
|
+
return CurrentPeriodUsage(
|
|
985
|
+
tier=data.get("tier", "unknown"),
|
|
986
|
+
period_start=_parse_dt(data.get("period_start")),
|
|
987
|
+
period_end=_parse_dt(data.get("period_end")),
|
|
988
|
+
qurls_created=data.get("qurls_created", 0),
|
|
989
|
+
active_qurls=data.get("active_qurls", 0),
|
|
990
|
+
cost_estimate=_parse_usage_cost(data.get("cost_estimate")),
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def parse_daily_usage(data: dict[str, Any]) -> DailyUsage:
|
|
995
|
+
"""Parse daily usage breakdown."""
|
|
996
|
+
return DailyUsage(
|
|
997
|
+
tier=data.get("tier", "unknown"),
|
|
998
|
+
period_start=_parse_dt(data.get("period_start")),
|
|
999
|
+
period_end=_parse_dt(data.get("period_end")),
|
|
1000
|
+
daily=[
|
|
1001
|
+
UsageDailyEntry(
|
|
1002
|
+
date=item.get("date", ""),
|
|
1003
|
+
qurls_created=item.get("qurls_created", 0),
|
|
1004
|
+
)
|
|
1005
|
+
for item in data.get("daily") or []
|
|
1006
|
+
if isinstance(item, dict)
|
|
1007
|
+
],
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def parse_customer(data: dict[str, Any]) -> Customer:
|
|
1012
|
+
"""Parse customer profile data."""
|
|
1013
|
+
# The current OpenAPI schema is an integer; tolerate older/future object
|
|
1014
|
+
# wrappers without letting bool sneak through Python's int hierarchy.
|
|
1015
|
+
current_period_usage = data.get("current_period_usage", 0)
|
|
1016
|
+
if isinstance(current_period_usage, bool):
|
|
1017
|
+
current_period_usage_count = 0
|
|
1018
|
+
elif isinstance(current_period_usage, int):
|
|
1019
|
+
current_period_usage_count = current_period_usage
|
|
1020
|
+
elif isinstance(current_period_usage, dict):
|
|
1021
|
+
count = current_period_usage.get("count", 0)
|
|
1022
|
+
current_period_usage_count = (
|
|
1023
|
+
count if isinstance(count, int) and not isinstance(count, bool) else 0
|
|
1024
|
+
)
|
|
1025
|
+
else:
|
|
1026
|
+
current_period_usage_count = 0
|
|
1027
|
+
return Customer(
|
|
1028
|
+
tier=data.get("tier", "unknown"),
|
|
1029
|
+
spending_cap_cents=data.get("spending_cap_cents", 0),
|
|
1030
|
+
# Wire name is `current_period_usage`; SDK suffixes `_count` to
|
|
1031
|
+
# avoid confusion with the richer CurrentPeriodUsage response.
|
|
1032
|
+
current_period_usage_count=current_period_usage_count,
|
|
1033
|
+
frozen=data.get("frozen", False),
|
|
1034
|
+
frozen_reason=data.get("frozen_reason"),
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def parse_checkout_session(data: dict[str, Any]) -> CheckoutSession:
|
|
1039
|
+
"""Parse a Stripe checkout session response."""
|
|
1040
|
+
return CheckoutSession(url=data["url"])
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def parse_portal_session(data: dict[str, Any]) -> PortalSession:
|
|
1044
|
+
"""Parse a Stripe billing portal session response."""
|
|
1045
|
+
return PortalSession(url=data["url"])
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def parse_invoice(data: dict[str, Any]) -> Invoice:
|
|
1049
|
+
"""Parse a billing invoice summary."""
|
|
1050
|
+
return Invoice(
|
|
1051
|
+
id=data.get("id", ""),
|
|
1052
|
+
amount_cents=data.get("amount_cents", 0),
|
|
1053
|
+
status=data.get("status", ""),
|
|
1054
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
1055
|
+
pdf_url=data.get("pdf_url"),
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def parse_invoice_list_output(
|
|
1060
|
+
data: Any, meta: dict[str, Any] | None
|
|
1061
|
+
) -> InvoiceListOutput:
|
|
1062
|
+
"""Parse billing invoice list output."""
|
|
1063
|
+
next_cursor, has_more = _meta_page(meta)
|
|
1064
|
+
# qurl-service returns billing invoices as {"invoices": [...]}, unlike
|
|
1065
|
+
# the top-level array shape used by most list endpoints.
|
|
1066
|
+
if isinstance(data, dict):
|
|
1067
|
+
raw_invoices = data.get("invoices")
|
|
1068
|
+
if raw_invoices is None and data:
|
|
1069
|
+
logger.debug(
|
|
1070
|
+
"parse_invoice_list_output: expected data.invoices, got keys=%s",
|
|
1071
|
+
list(data.keys()),
|
|
1072
|
+
)
|
|
1073
|
+
else:
|
|
1074
|
+
raw_invoices = None
|
|
1075
|
+
if data:
|
|
1076
|
+
logger.debug(
|
|
1077
|
+
"parse_invoice_list_output: expected object with invoices, got %s",
|
|
1078
|
+
type(data).__name__,
|
|
1079
|
+
)
|
|
1080
|
+
invoices = _parse_list_items(raw_invoices, parse_invoice)
|
|
1081
|
+
return InvoiceListOutput(invoices=invoices, next_cursor=next_cursor, has_more=has_more)
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _parse_connector_stats(data: dict[str, Any] | None) -> ConnectorInstallationStats | None:
|
|
1085
|
+
if data is None:
|
|
1086
|
+
return None
|
|
1087
|
+
return ConnectorInstallationStats(
|
|
1088
|
+
resources=data.get("resources", 0),
|
|
1089
|
+
qurls=data.get("qurls", 0),
|
|
1090
|
+
accesses_24h=data.get("accesses_24h", 0),
|
|
1091
|
+
accesses_7d=data.get("accesses_7d", 0),
|
|
1092
|
+
errors_24h=data.get("errors_24h", 0),
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _parse_connector_capabilities(
|
|
1097
|
+
data: dict[str, Any] | None,
|
|
1098
|
+
) -> ConnectorInstallationCapabilities | None:
|
|
1099
|
+
if data is None:
|
|
1100
|
+
return None
|
|
1101
|
+
return ConnectorInstallationCapabilities(
|
|
1102
|
+
configure=data.get("configure", False),
|
|
1103
|
+
disconnect=data.get("disconnect", False),
|
|
1104
|
+
reauth=data.get("reauth", False),
|
|
1105
|
+
view_activity=data.get("view_activity", False),
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def parse_connector_installation(data: dict[str, Any]) -> ConnectorInstallation:
|
|
1110
|
+
"""Parse a connector installation summary."""
|
|
1111
|
+
return ConnectorInstallation(
|
|
1112
|
+
installation_id=data["installation_id"],
|
|
1113
|
+
plugin_id=data["plugin_id"],
|
|
1114
|
+
label=data["label"],
|
|
1115
|
+
subject_kind=data["subject_kind"],
|
|
1116
|
+
subject_display_name=data["subject_display_name"],
|
|
1117
|
+
status=data["status"],
|
|
1118
|
+
installed_at=_parse_dt(data.get("installed_at")),
|
|
1119
|
+
last_activity_at=_parse_dt(data.get("last_activity_at")),
|
|
1120
|
+
stats=_parse_connector_stats(data.get("stats")),
|
|
1121
|
+
capabilities=_parse_connector_capabilities(data.get("capabilities")),
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def parse_connector_installation_list_output(
|
|
1126
|
+
data: Any, meta: dict[str, Any] | None
|
|
1127
|
+
) -> ConnectorInstallationListOutput:
|
|
1128
|
+
"""Parse connector installation list output."""
|
|
1129
|
+
next_cursor, has_more = _meta_page(meta)
|
|
1130
|
+
installations = _parse_list_items(data, parse_connector_installation)
|
|
1131
|
+
return ConnectorInstallationListOutput(
|
|
1132
|
+
installations=installations, next_cursor=next_cursor, has_more=has_more
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def parse_agent_bootstrap_output(data: dict[str, Any]) -> AgentBootstrapOutput:
|
|
1137
|
+
"""Parse connector agent bootstrap output."""
|
|
1138
|
+
raw_peer = data.get("nhp_server_peer")
|
|
1139
|
+
peer = raw_peer if isinstance(raw_peer, dict) else {}
|
|
1140
|
+
return AgentBootstrapOutput(
|
|
1141
|
+
agent_id=data.get("agent_id", ""),
|
|
1142
|
+
registered_at=_parse_dt(data.get("registered_at")),
|
|
1143
|
+
nhp_server_peer=NHPServerPeerInfo(
|
|
1144
|
+
public_key_b64=peer.get("public_key_b64", ""),
|
|
1145
|
+
host=peer.get("host", ""),
|
|
1146
|
+
port=peer.get("port", 0),
|
|
1147
|
+
expire_time=peer.get("expire_time", 0),
|
|
1148
|
+
),
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _validate_batch_create_shape(data: Any) -> None:
|
|
1153
|
+
"""Defense-in-depth structural check for batch_create responses.
|
|
1154
|
+
|
|
1155
|
+
The ``batch_create`` endpoint whitelists HTTP 400 into the success
|
|
1156
|
+
path so the populated ``BatchCreateOutput`` body is surfaced instead
|
|
1157
|
+
of being swallowed by the generic error path. If the API ever returns
|
|
1158
|
+
400 with a *different* body (e.g., a plain validation error envelope,
|
|
1159
|
+
a proxy error, or malformed JSON), ``parse_batch_create_output`` would
|
|
1160
|
+
silently produce ``(succeeded=0, failed=0, results=[])`` via its
|
|
1161
|
+
``.get()`` defaults — the caller would get no indication anything went
|
|
1162
|
+
wrong.
|
|
1163
|
+
|
|
1164
|
+
Raise a clear error instead when the shape doesn't match. The error
|
|
1165
|
+
message intentionally does not embed the raw body — an unexpected
|
|
1166
|
+
body could contain sensitive data (auth details, request echoes)
|
|
1167
|
+
and error strings may end up in client-side logs. Structural hints
|
|
1168
|
+
(type name, top-level keys) are emitted at DEBUG level for
|
|
1169
|
+
production triage, which is safe because JSON key names come from
|
|
1170
|
+
the API's published schema — not user-supplied data.
|
|
1171
|
+
"""
|
|
1172
|
+
|
|
1173
|
+
def _fail(reason: str, *, top_level_keys: list[str] | None = None) -> ValidationError:
|
|
1174
|
+
# DEBUG log carries structural hints (type + top-level key names
|
|
1175
|
+
# only — JSON keys come from the published schema, not user
|
|
1176
|
+
# data) so operators can triage shape-guard trips without
|
|
1177
|
+
# leaking raw body content into logs.
|
|
1178
|
+
logger.debug(
|
|
1179
|
+
"batch_create shape guard tripped: %s (type=%s, top_level_keys=%s)",
|
|
1180
|
+
reason,
|
|
1181
|
+
type(data).__name__,
|
|
1182
|
+
top_level_keys,
|
|
1183
|
+
)
|
|
1184
|
+
# Uses `ValidationError` (subclass) not bare `QURLError` so
|
|
1185
|
+
# `except ValidationError` catches shape-guard trips; `code=
|
|
1186
|
+
# "unexpected_response"` distinguishes from client-side
|
|
1187
|
+
# preflight (`client_validation`). `status=0` is the SDK
|
|
1188
|
+
# convention for all client-detected failures (not real HTTP
|
|
1189
|
+
# status). See qurl-typescript's `unexpectedResponseError`.
|
|
1190
|
+
return ValidationError(
|
|
1191
|
+
status=0,
|
|
1192
|
+
code="unexpected_response",
|
|
1193
|
+
title="Unexpected Response",
|
|
1194
|
+
detail="Unexpected response shape from POST /v1/qurls/batch",
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
if not isinstance(data, dict):
|
|
1198
|
+
raise _fail("not a dict")
|
|
1199
|
+
top_keys = sorted(data.keys())
|
|
1200
|
+
# `bool` is a subclass of `int` in Python, so a response with
|
|
1201
|
+
# `"succeeded": True` would silently pass an `isinstance(..., int)`
|
|
1202
|
+
# check and then slip a truthy bool into the counts. Reject
|
|
1203
|
+
# explicitly — matches the same guard in `_require_max_sessions_in_range`.
|
|
1204
|
+
succeeded = data.get("succeeded")
|
|
1205
|
+
failed = data.get("failed")
|
|
1206
|
+
if (
|
|
1207
|
+
not isinstance(succeeded, int)
|
|
1208
|
+
or isinstance(succeeded, bool)
|
|
1209
|
+
or not isinstance(failed, int)
|
|
1210
|
+
or isinstance(failed, bool)
|
|
1211
|
+
):
|
|
1212
|
+
raise _fail("succeeded/failed missing or wrong type", top_level_keys=top_keys)
|
|
1213
|
+
if not isinstance(data.get("results"), list):
|
|
1214
|
+
raise _fail("results missing or not a list", top_level_keys=top_keys)
|
|
1215
|
+
results = data["results"]
|
|
1216
|
+
# Arithmetic invariant: `succeeded + failed` must equal the number of
|
|
1217
|
+
# result entries. A mismatch indicates either a proxy/middleware
|
|
1218
|
+
# mangled the response or the API returned inconsistent counts —
|
|
1219
|
+
# both cases warrant raising rather than trusting the data.
|
|
1220
|
+
if succeeded + failed != len(results):
|
|
1221
|
+
raise _fail(
|
|
1222
|
+
f"counts/results length mismatch (succeeded={succeeded}, "
|
|
1223
|
+
f"failed={failed}, len(results)={len(results)})",
|
|
1224
|
+
top_level_keys=top_keys,
|
|
1225
|
+
)
|
|
1226
|
+
# Each entry must carry a boolean `success` discriminant so consumers
|
|
1227
|
+
# can reliably branch on it — anything else would break the
|
|
1228
|
+
# BatchItemResult contract. Deeper per-field validation is
|
|
1229
|
+
# intentionally left to the API.
|
|
1230
|
+
for i, entry in enumerate(results):
|
|
1231
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("success"), bool):
|
|
1232
|
+
raise _fail(
|
|
1233
|
+
f"results[{i}] missing boolean 'success' discriminant",
|
|
1234
|
+
top_level_keys=top_keys,
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def parse_batch_create_output(data: dict[str, Any]) -> BatchCreateOutput:
|
|
1239
|
+
"""Parse a BatchCreateOutput from API response data.
|
|
1240
|
+
|
|
1241
|
+
Runs :func:`_validate_batch_create_shape` internally before
|
|
1242
|
+
parsing, so a malformed envelope raises :class:`ValidationError`
|
|
1243
|
+
here rather than silently producing an empty result. This enforces
|
|
1244
|
+
the shape contract at the parser boundary rather than relying on
|
|
1245
|
+
every call site to remember to validate first — previously the
|
|
1246
|
+
validation was called explicitly by ``batch_create`` and a future
|
|
1247
|
+
refactor that forgot the step would silently get ``(succeeded=0,
|
|
1248
|
+
failed=0, results=[])`` from the ``.get()`` defaults below.
|
|
1249
|
+
"""
|
|
1250
|
+
_validate_batch_create_shape(data)
|
|
1251
|
+
results: list[BatchItemResult] = []
|
|
1252
|
+
for item in data.get("results") or []:
|
|
1253
|
+
err = None
|
|
1254
|
+
if item.get("error"):
|
|
1255
|
+
e = item["error"]
|
|
1256
|
+
err = BatchItemError(
|
|
1257
|
+
code=e.get("code", ""),
|
|
1258
|
+
message=e.get("message", ""),
|
|
1259
|
+
)
|
|
1260
|
+
results.append(
|
|
1261
|
+
BatchItemResult(
|
|
1262
|
+
index=item.get("index", 0),
|
|
1263
|
+
success=item.get("success", False),
|
|
1264
|
+
resource_id=item.get("resource_id"),
|
|
1265
|
+
qurl_link=item.get("qurl_link"),
|
|
1266
|
+
qurl_site=item.get("qurl_site"),
|
|
1267
|
+
branded_domain=item.get("branded_domain"),
|
|
1268
|
+
expires_at=_parse_dt(item.get("expires_at")),
|
|
1269
|
+
error=err,
|
|
1270
|
+
)
|
|
1271
|
+
)
|
|
1272
|
+
return BatchCreateOutput(
|
|
1273
|
+
succeeded=data.get("succeeded", 0),
|
|
1274
|
+
failed=data.get("failed", 0),
|
|
1275
|
+
results=results,
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def parse_error(response: httpx.Response) -> QURLError:
|
|
1280
|
+
"""Parse an API error response into the appropriate QURLError subclass.
|
|
1281
|
+
|
|
1282
|
+
Handles the full RFC 7807 Problem Details shape (``type``, ``title``,
|
|
1283
|
+
``status``, ``detail``, ``instance``, ``code``) plus the pre-RFC-7807
|
|
1284
|
+
legacy ``{error: {code, message}}`` envelope for backward compatibility.
|
|
1285
|
+
|
|
1286
|
+
The ``detail`` fallback chain is:
|
|
1287
|
+
1. ``err.detail`` — RFC 7807 primary
|
|
1288
|
+
2. ``err.message`` — legacy pre-RFC-7807 shape
|
|
1289
|
+
3. ``err.title`` — RFC 7807 required field
|
|
1290
|
+
4. ``HTTP {status}`` — final safety net
|
|
1291
|
+
|
|
1292
|
+
This prevents ``"Title (403): "`` when the API omits ``detail``.
|
|
1293
|
+
"""
|
|
1294
|
+
retry_after = None
|
|
1295
|
+
if response.status_code == 429:
|
|
1296
|
+
retry_after_header = response.headers.get("Retry-After")
|
|
1297
|
+
# Per RFC 7231 §7.1.3, `Retry-After` can be either a
|
|
1298
|
+
# delay-seconds integer OR an HTTP-date. The `.isdigit()` check
|
|
1299
|
+
# accepts only the integer form — HTTP-date strings contain
|
|
1300
|
+
# letters/spaces/commas and deliberately fall through to `None`,
|
|
1301
|
+
# which causes the retry path to use exponential backoff
|
|
1302
|
+
# instead. This is a safe fallback: we don't honor the server's
|
|
1303
|
+
# exact hint, but we also don't hang waiting for a parsed date
|
|
1304
|
+
# value or crash on an unexpected header format. If full
|
|
1305
|
+
# HTTP-date support becomes a requirement, replace `.isdigit()`
|
|
1306
|
+
# with a `parsedate_to_datetime`-based parse.
|
|
1307
|
+
if retry_after_header and retry_after_header.isdigit():
|
|
1308
|
+
retry_after = int(retry_after_header)
|
|
1309
|
+
|
|
1310
|
+
# Pick the right subclass, defaulting to ServerError for 5xx or QURLError
|
|
1311
|
+
cls: type[QURLError]
|
|
1312
|
+
if response.status_code >= 500:
|
|
1313
|
+
cls = ServerError
|
|
1314
|
+
else:
|
|
1315
|
+
cls = _ERROR_CLASS_MAP.get(response.status_code, QURLError)
|
|
1316
|
+
|
|
1317
|
+
try:
|
|
1318
|
+
envelope = response.json()
|
|
1319
|
+
# `.get("error") or {}` — not the more-common `.get("error", {})` —
|
|
1320
|
+
# because the API may return `"error": null` explicitly, not just
|
|
1321
|
+
# omit the key. Both cases must collapse to the empty-dict default
|
|
1322
|
+
# so the subsequent `err.get(...)` chains don't raise
|
|
1323
|
+
# `AttributeError` on `None`. Same pattern is applied to `meta`
|
|
1324
|
+
# below and to `envelope.get("data")` callers elsewhere.
|
|
1325
|
+
err = envelope.get("error") or {}
|
|
1326
|
+
title = err.get("title") or response.reason_phrase or ""
|
|
1327
|
+
detail = (
|
|
1328
|
+
err.get("detail")
|
|
1329
|
+
or err.get("message") # legacy envelope
|
|
1330
|
+
or title
|
|
1331
|
+
or f"HTTP {response.status_code}"
|
|
1332
|
+
)
|
|
1333
|
+
return cls(
|
|
1334
|
+
status=err.get("status", response.status_code),
|
|
1335
|
+
code=err.get("code", "unknown"),
|
|
1336
|
+
title=title,
|
|
1337
|
+
detail=detail,
|
|
1338
|
+
type=err.get("type"),
|
|
1339
|
+
instance=err.get("instance"),
|
|
1340
|
+
invalid_fields=err.get("invalid_fields"),
|
|
1341
|
+
request_id=(envelope.get("meta") or {}).get("request_id"),
|
|
1342
|
+
meta=envelope.get("meta"),
|
|
1343
|
+
retry_after=retry_after,
|
|
1344
|
+
)
|
|
1345
|
+
except (ValueError, KeyError, TypeError):
|
|
1346
|
+
return cls(
|
|
1347
|
+
status=response.status_code,
|
|
1348
|
+
code="unknown",
|
|
1349
|
+
title=response.reason_phrase or "",
|
|
1350
|
+
detail=response.text or f"HTTP {response.status_code}",
|
|
1351
|
+
retry_after=retry_after,
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def retry_delay(attempt: int, last_error: Exception | None) -> float:
|
|
1356
|
+
"""Compute retry delay with exponential backoff, jitter, and Retry-After cap."""
|
|
1357
|
+
if isinstance(last_error, QURLError) and last_error.retry_after:
|
|
1358
|
+
return min(float(last_error.retry_after), 30.0)
|
|
1359
|
+
base: float = 0.5 * (2 ** (attempt - 1))
|
|
1360
|
+
jitter = random.random() * base * 0.5 # noqa: S311
|
|
1361
|
+
return min(base + jitter, 30.0)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def build_list_params(
|
|
1365
|
+
limit: int | None,
|
|
1366
|
+
cursor: str | None,
|
|
1367
|
+
*,
|
|
1368
|
+
status: str | None = None,
|
|
1369
|
+
q: str | None = None,
|
|
1370
|
+
sort: str | None = None,
|
|
1371
|
+
created_after: datetime | str | None = None,
|
|
1372
|
+
created_before: datetime | str | None = None,
|
|
1373
|
+
expires_before: datetime | str | None = None,
|
|
1374
|
+
expires_after: datetime | str | None = None,
|
|
1375
|
+
) -> dict[str, str]:
|
|
1376
|
+
"""Build query params for list endpoints, dropping None values."""
|
|
1377
|
+
# Per the OpenAPI spec (GET /v1/qurls → limit: integer, minimum: 1,
|
|
1378
|
+
# maximum: 100, default: 20). Client-side validation catches obvious
|
|
1379
|
+
# mistakes before a round-trip, matching the existing style for
|
|
1380
|
+
# max_sessions, tag count, URL length. Omitting `limit` (None) lets
|
|
1381
|
+
# the server apply its default page size.
|
|
1382
|
+
if limit is not None:
|
|
1383
|
+
if not isinstance(limit, int) or isinstance(limit, bool):
|
|
1384
|
+
raise ValueError(
|
|
1385
|
+
f"limit: must be an integer between 1 and 100 (got {type(limit).__name__})"
|
|
1386
|
+
)
|
|
1387
|
+
if limit < 1 or limit > 100:
|
|
1388
|
+
raise ValueError(
|
|
1389
|
+
f"limit: must be an integer between 1 and 100 (got {limit})"
|
|
1390
|
+
)
|
|
1391
|
+
# ``status`` is a QURLStatus (Literal | str) — covered by the ``str`` arm.
|
|
1392
|
+
# Ordered most-specific to least-specific: datetime/int are concrete
|
|
1393
|
+
# types, str is the widening arm, None is the "drop" sentinel.
|
|
1394
|
+
pairs: dict[str, datetime | int | str | None] = {
|
|
1395
|
+
"limit": limit,
|
|
1396
|
+
"cursor": cursor,
|
|
1397
|
+
"status": status,
|
|
1398
|
+
"q": q,
|
|
1399
|
+
"sort": sort,
|
|
1400
|
+
"created_after": created_after,
|
|
1401
|
+
"created_before": created_before,
|
|
1402
|
+
"expires_before": expires_before,
|
|
1403
|
+
"expires_after": expires_after,
|
|
1404
|
+
}
|
|
1405
|
+
return build_query_params(pairs)
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def mask_key(api_key: str) -> str:
|
|
1409
|
+
"""Mask an API key for display, hiding JWT suffix fragments."""
|
|
1410
|
+
if api_key.startswith("eyJ") and _JWT_LIKE_PATTERN.match(api_key):
|
|
1411
|
+
return api_key[:4] + "***"
|
|
1412
|
+
if len(api_key) > 8:
|
|
1413
|
+
return api_key[:4] + "***" + api_key[-4:]
|
|
1414
|
+
return "***"
|