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/_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 "***"