qurl-python 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- layerv_qurl/__init__.py +6 -0
- layerv_qurl/_pagination.py +54 -0
- layerv_qurl/_utils.py +277 -48
- layerv_qurl/async_client.py +247 -63
- layerv_qurl/client.py +258 -79
- layerv_qurl/crid.py +56 -0
- layerv_qurl/errors.py +6 -1
- layerv_qurl/langchain.py +7 -2
- layerv_qurl/types.py +86 -9
- qurl_python-0.5.0.dist-info/METADATA +618 -0
- qurl_python-0.5.0.dist-info/RECORD +14 -0
- {qurl_python-0.3.0.dist-info → qurl_python-0.5.0.dist-info}/WHEEL +1 -1
- qurl_python-0.3.0.dist-info/METADATA +0 -391
- qurl_python-0.3.0.dist-info/RECORD +0 -12
- {qurl_python-0.3.0.dist-info → qurl_python-0.5.0.dist-info}/licenses/LICENSE +0 -0
layerv_qurl/__init__.py
CHANGED
|
@@ -45,6 +45,7 @@ from layerv_qurl.types import (
|
|
|
45
45
|
ConnectorInstallationListOutput,
|
|
46
46
|
ConnectorInstallationStats,
|
|
47
47
|
CreateOutput,
|
|
48
|
+
CredentialClaim,
|
|
48
49
|
CurrentPeriodUsage,
|
|
49
50
|
Customer,
|
|
50
51
|
DailyUsage,
|
|
@@ -58,6 +59,7 @@ from layerv_qurl.types import (
|
|
|
58
59
|
MintOutput,
|
|
59
60
|
Portal,
|
|
60
61
|
PortalSession,
|
|
62
|
+
Publisher,
|
|
61
63
|
Quota,
|
|
62
64
|
QuotaPlan,
|
|
63
65
|
QURLStatus,
|
|
@@ -72,6 +74,7 @@ from layerv_qurl.types import (
|
|
|
72
74
|
Session,
|
|
73
75
|
SessionListOutput,
|
|
74
76
|
SessionTerminateOutput,
|
|
77
|
+
ShareLink,
|
|
75
78
|
TokenStatus,
|
|
76
79
|
Usage,
|
|
77
80
|
UsageCostEstimate,
|
|
@@ -129,6 +132,8 @@ __all__ = [
|
|
|
129
132
|
"InvoiceListOutput",
|
|
130
133
|
"Portal",
|
|
131
134
|
"PortalSession",
|
|
135
|
+
"Publisher",
|
|
136
|
+
"ShareLink",
|
|
132
137
|
"QuotaPlan",
|
|
133
138
|
"QURLStatus",
|
|
134
139
|
"RedeemAccessCodeOutput",
|
|
@@ -154,6 +159,7 @@ __all__ = [
|
|
|
154
159
|
"AccessPolicy",
|
|
155
160
|
"AccessToken",
|
|
156
161
|
"CreateOutput",
|
|
162
|
+
"CredentialClaim",
|
|
157
163
|
"ListOutput",
|
|
158
164
|
"MintOutput",
|
|
159
165
|
"QURL",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Cursor traversal shared by the synchronous and asynchronous clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, TypeVar
|
|
6
|
+
|
|
7
|
+
from layerv_qurl.errors import QURLError
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CursorPage(Protocol):
|
|
14
|
+
next_cursor: str | None
|
|
15
|
+
has_more: bool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
Page = TypeVar("Page", bound=CursorPage)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _next_cursor(page: CursorPage, seen: set[str]) -> str | None:
|
|
22
|
+
if not page.has_more:
|
|
23
|
+
return None
|
|
24
|
+
cursor = page.next_cursor
|
|
25
|
+
if not isinstance(cursor, str) or not cursor or cursor in seen:
|
|
26
|
+
raise QURLError(
|
|
27
|
+
status=0,
|
|
28
|
+
code="unexpected_response",
|
|
29
|
+
title="Pagination requires a new non-empty cursor when has_more is true",
|
|
30
|
+
)
|
|
31
|
+
seen.add(cursor)
|
|
32
|
+
return cursor
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pages(fetch: Callable[[str | None], Page]) -> Iterator[Page]:
|
|
36
|
+
cursor = None
|
|
37
|
+
seen: set[str] = set()
|
|
38
|
+
while True:
|
|
39
|
+
page = fetch(cursor)
|
|
40
|
+
yield page
|
|
41
|
+
cursor = _next_cursor(page, seen)
|
|
42
|
+
if cursor is None:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def async_pages(fetch: Callable[[str | None], Awaitable[Page]]) -> AsyncIterator[Page]:
|
|
47
|
+
cursor = None
|
|
48
|
+
seen: set[str] = set()
|
|
49
|
+
while True:
|
|
50
|
+
page = await fetch(cursor)
|
|
51
|
+
yield page
|
|
52
|
+
cursor = _next_cursor(page, seen)
|
|
53
|
+
if cursor is None:
|
|
54
|
+
return
|
layerv_qurl/_utils.py
CHANGED
|
@@ -10,6 +10,7 @@ not import them directly.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import base64
|
|
13
14
|
import dataclasses
|
|
14
15
|
import functools
|
|
15
16
|
import logging
|
|
@@ -17,6 +18,7 @@ import random
|
|
|
17
18
|
import re
|
|
18
19
|
import secrets
|
|
19
20
|
import time
|
|
21
|
+
import unicodedata
|
|
20
22
|
import uuid
|
|
21
23
|
from collections.abc import Iterable, Mapping, Set
|
|
22
24
|
from datetime import datetime, timedelta
|
|
@@ -25,6 +27,9 @@ from importlib.metadata import version as _pkg_version
|
|
|
25
27
|
from typing import TYPE_CHECKING, Any, TypeVar
|
|
26
28
|
from urllib.parse import quote, urlsplit
|
|
27
29
|
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
from layerv_qurl.crid import key_matches
|
|
28
33
|
from layerv_qurl.errors import (
|
|
29
34
|
AuthenticationError,
|
|
30
35
|
AuthorizationError,
|
|
@@ -56,6 +61,7 @@ from layerv_qurl.types import (
|
|
|
56
61
|
ConnectorInstallationListOutput,
|
|
57
62
|
ConnectorInstallationStats,
|
|
58
63
|
CreateOutput,
|
|
64
|
+
CredentialClaim,
|
|
59
65
|
CurrentPeriodUsage,
|
|
60
66
|
Customer,
|
|
61
67
|
DailyUsage,
|
|
@@ -69,6 +75,7 @@ from layerv_qurl.types import (
|
|
|
69
75
|
MintOutput,
|
|
70
76
|
Portal,
|
|
71
77
|
PortalSession,
|
|
78
|
+
Publisher,
|
|
72
79
|
Quota,
|
|
73
80
|
RateLimits,
|
|
74
81
|
RedeemAccessCodeOutput,
|
|
@@ -80,6 +87,7 @@ from layerv_qurl.types import (
|
|
|
80
87
|
Session,
|
|
81
88
|
SessionListOutput,
|
|
82
89
|
SessionTerminateOutput,
|
|
90
|
+
ShareLink,
|
|
83
91
|
Usage,
|
|
84
92
|
UsageCostEstimate,
|
|
85
93
|
UsageDailyEntry,
|
|
@@ -95,7 +103,6 @@ from layerv_qurl.types import (
|
|
|
95
103
|
if TYPE_CHECKING:
|
|
96
104
|
from collections.abc import Callable
|
|
97
105
|
|
|
98
|
-
import httpx
|
|
99
106
|
|
|
100
107
|
logger = logging.getLogger("layerv_qurl")
|
|
101
108
|
_T = TypeVar("_T")
|
|
@@ -104,9 +111,9 @@ DEFAULT_BASE_URL = "https://api.layerv.ai"
|
|
|
104
111
|
DEFAULT_TIMEOUT = 30.0
|
|
105
112
|
DEFAULT_MAX_RETRIES = 3
|
|
106
113
|
RETRYABLE_STATUS = {429, 502, 503, 504}
|
|
107
|
-
# POST
|
|
108
|
-
#
|
|
109
|
-
|
|
114
|
+
# POST and PATCH can consume tokens or extend expiry after committing;
|
|
115
|
+
# service 5xx responses are not cached for durable replay.
|
|
116
|
+
RETRYABLE_STATUS_NON_IDEMPOTENT = {429}
|
|
110
117
|
# POST/PATCH, plus any future PUT endpoints from the API contract, carry a
|
|
111
118
|
# qurl-service replay key so retried writes are tied to one logical operation.
|
|
112
119
|
# DELETE keeps the wider HTTP retry set without a key because repeated deletes
|
|
@@ -144,10 +151,11 @@ def default_user_agent() -> str:
|
|
|
144
151
|
return f"qurl-python-sdk/{v}"
|
|
145
152
|
|
|
146
153
|
|
|
147
|
-
def validate_id(value: str, name: str = "resource_id") -> str:
|
|
154
|
+
def validate_id(value: str, name: str = "resource_id", *, sensitive: bool = False) -> str:
|
|
148
155
|
"""Validate that an ID is non-empty and contains no path traversal characters."""
|
|
149
|
-
if not value or not _RESOURCE_ID_RE.
|
|
150
|
-
|
|
156
|
+
if not value or not _RESOURCE_ID_RE.fullmatch(value):
|
|
157
|
+
shown = "<redacted>" if sensitive else repr(value)
|
|
158
|
+
raise ValueError(f"Invalid {name}: {shown}")
|
|
151
159
|
return value
|
|
152
160
|
|
|
153
161
|
|
|
@@ -197,7 +205,33 @@ def build_body(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
|
197
205
|
return body
|
|
198
206
|
|
|
199
207
|
|
|
200
|
-
def
|
|
208
|
+
def build_credential_scopes(
|
|
209
|
+
kind: str, scopes: Any, target: Any = None, claims: Any = None
|
|
210
|
+
) -> list[str] | None:
|
|
211
|
+
"""Validate the kind-dependent credential fields and return ``scopes``.
|
|
212
|
+
|
|
213
|
+
The three fields are mutually exclusive by kind, and the server
|
|
214
|
+
returns 400 ``invalid_input`` for either mismatch. Account ``api_key``
|
|
215
|
+
credentials carry caller-chosen ``scopes`` and accept neither
|
|
216
|
+
``target`` nor ``claims``; ``enrollment_token`` scopes are
|
|
217
|
+
server-assigned from ``target``, so sending ``scopes`` is rejected.
|
|
218
|
+
"""
|
|
219
|
+
if kind == "enrollment_token":
|
|
220
|
+
if scopes is not None:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
"scopes: not accepted for kind='enrollment_token'; the server "
|
|
223
|
+
"assigns them from target"
|
|
224
|
+
)
|
|
225
|
+
return None
|
|
226
|
+
for field, value in (("target", target), ("claims", claims)):
|
|
227
|
+
if value is not None:
|
|
228
|
+
raise ValueError(f"{field}: only accepted for kind='enrollment_token'")
|
|
229
|
+
if scopes is None:
|
|
230
|
+
raise ValueError(f"scopes: required for kind={kind!r}")
|
|
231
|
+
return build_string_list(scopes, "scopes")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_string_list(value: Any, field: str, *, allow_empty: bool = False) -> list[str]:
|
|
201
235
|
"""Build a non-empty list of strings without accepting strings or mappings."""
|
|
202
236
|
if isinstance(value, str):
|
|
203
237
|
raise ValueError(f"{field}: must be an iterable of strings, not a string")
|
|
@@ -208,7 +242,7 @@ def build_string_list(value: Any, field: str) -> list[str]:
|
|
|
208
242
|
if not isinstance(value, Iterable):
|
|
209
243
|
raise ValueError(f"{field}: must be an iterable of strings")
|
|
210
244
|
items = list(value)
|
|
211
|
-
if not items:
|
|
245
|
+
if not items and not allow_empty:
|
|
212
246
|
raise ValueError(f"{field}: cannot be empty; pass at least one value")
|
|
213
247
|
if any(not isinstance(item, str) for item in items):
|
|
214
248
|
raise ValueError(f"{field}: all values must be strings")
|
|
@@ -323,6 +357,7 @@ def require_nonempty_update(body: dict[str, Any], method: str, fields: str) -> N
|
|
|
323
357
|
MAX_TARGET_URL = 2048
|
|
324
358
|
MAX_LABEL = 500
|
|
325
359
|
MAX_DESCRIPTION = 500
|
|
360
|
+
MAX_RESOURCE_NAME = 100
|
|
326
361
|
MAX_CUSTOM_DOMAIN = 253
|
|
327
362
|
MAX_MAX_SESSIONS = 1000
|
|
328
363
|
MAX_TAGS = 10
|
|
@@ -371,7 +406,6 @@ _RESERVED_ALIASES = frozenset(
|
|
|
371
406
|
"*",
|
|
372
407
|
}
|
|
373
408
|
)
|
|
374
|
-
RESOURCE_ID_PREFIX = "r_"
|
|
375
409
|
# target_url must use an http(s) scheme per the API's SSRF protection.
|
|
376
410
|
# This is a cheap client-side sanity check — the server is still the
|
|
377
411
|
# authoritative validator (e.g. it rejects localhost, cloud metadata,
|
|
@@ -452,9 +486,26 @@ def _require_valid_tags(tags: list[str] | None) -> None:
|
|
|
452
486
|
)
|
|
453
487
|
|
|
454
488
|
|
|
489
|
+
def validate_resource_name(name: str | None) -> None:
|
|
490
|
+
"""Mirror the API's ValidatePreviewName rule (100 characters, one line, no hidden chars)."""
|
|
491
|
+
if name is None:
|
|
492
|
+
return
|
|
493
|
+
validate_required_string(name, "name")
|
|
494
|
+
_require_max_length(name, "name", MAX_RESOURCE_NAME)
|
|
495
|
+
# Same rule as the service's ValidatePreviewName (100 code points; refuses
|
|
496
|
+
# Cc, Cf -- including ZWJ emoji sequences -- U+2028/9 and padding).
|
|
497
|
+
if name.strip() != name or any(
|
|
498
|
+
unicodedata.category(c) in {"Cc", "Cf", "Zl", "Zp"} for c in name
|
|
499
|
+
):
|
|
500
|
+
raise ValueError(
|
|
501
|
+
"name: must be one line without hidden characters or surrounding whitespace"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
455
505
|
def validate_create_input(
|
|
456
506
|
*,
|
|
457
507
|
target_url: str,
|
|
508
|
+
name: str | None = None,
|
|
458
509
|
label: str | None = None,
|
|
459
510
|
max_sessions: int | None = None,
|
|
460
511
|
custom_domain: str | None = None,
|
|
@@ -469,6 +520,7 @@ def validate_create_input(
|
|
|
469
520
|
# `target_url[:32]!r` trap for non-subscriptable inputs (None, int,
|
|
470
521
|
# bool, ...), surfacing a clean ValueError instead of TypeError.
|
|
471
522
|
_require_http_url(target_url, "target_url")
|
|
523
|
+
validate_resource_name(name)
|
|
472
524
|
_require_max_length(label, "label", MAX_LABEL)
|
|
473
525
|
_require_max_length(custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN)
|
|
474
526
|
_require_max_sessions_in_range(max_sessions)
|
|
@@ -507,29 +559,21 @@ def validate_mint_input(
|
|
|
507
559
|
_require_max_sessions_in_range(max_sessions)
|
|
508
560
|
|
|
509
561
|
|
|
510
|
-
def
|
|
511
|
-
"""
|
|
562
|
+
def validate_resource_qurl_ids(resource_id: str, qurl_id: str) -> None:
|
|
563
|
+
"""Keep bearer tokens out of token-management paths and errors."""
|
|
564
|
+
validate_resource_locator(resource_id)
|
|
565
|
+
validate_id(qurl_id, "qurl_id", sensitive=True)
|
|
566
|
+
if not qurl_id.startswith("q_") or len(qurl_id) == 2:
|
|
567
|
+
raise ValueError("qurl_id must be the qURL display ID returned by the API")
|
|
512
568
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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.
|
|
569
|
+
|
|
570
|
+
def validate_resource_locator(resource_id: str) -> None:
|
|
571
|
+
"""Keep token IDs off resource-wide deletion; the API validates public keys."""
|
|
572
|
+
validate_id(resource_id, sensitive=True)
|
|
573
|
+
if resource_id.startswith(("q_", "at_")):
|
|
528
574
|
raise ValueError(
|
|
529
|
-
f"
|
|
530
|
-
|
|
531
|
-
"access token, use the token-scoped revoke endpoint (not yet "
|
|
532
|
-
"available in this SDK version)."
|
|
575
|
+
f"resource locator cannot start with {resource_id[:2]!r}; "
|
|
576
|
+
"use a CRID or resource public key, or revoke_resource_qurl for one token"
|
|
533
577
|
)
|
|
534
578
|
|
|
535
579
|
|
|
@@ -750,13 +794,17 @@ def validate_portal_target_url(target_url: str) -> None:
|
|
|
750
794
|
def build_protect_body(
|
|
751
795
|
*,
|
|
752
796
|
target_url: str,
|
|
797
|
+
name: str | None,
|
|
753
798
|
description: str | None,
|
|
754
799
|
tags: list[str] | None,
|
|
755
800
|
custom_domain: str | None,
|
|
756
801
|
alias: str | None,
|
|
802
|
+
private: bool | None = None,
|
|
757
803
|
) -> dict[str, Any]:
|
|
758
804
|
"""Validate protect-url inputs and build the create-resource request body."""
|
|
759
805
|
validate_portal_target_url(target_url)
|
|
806
|
+
validate_resource_name(name)
|
|
807
|
+
validate_optional_bool(private, "private")
|
|
760
808
|
validate_update_input(
|
|
761
809
|
description=description, tags=tags, custom_domain=custom_domain
|
|
762
810
|
)
|
|
@@ -764,10 +812,12 @@ def build_protect_body(
|
|
|
764
812
|
return build_body(
|
|
765
813
|
{
|
|
766
814
|
"target_url": target_url,
|
|
815
|
+
"name": name,
|
|
767
816
|
"description": description,
|
|
768
817
|
"tags": tags,
|
|
769
818
|
"custom_domain": custom_domain,
|
|
770
819
|
"alias": alias,
|
|
820
|
+
"private": private,
|
|
771
821
|
}
|
|
772
822
|
)
|
|
773
823
|
|
|
@@ -775,11 +825,12 @@ def build_protect_body(
|
|
|
775
825
|
def require_connector_resource(
|
|
776
826
|
resources: list[Resource], connector_id: str
|
|
777
827
|
) -> Resource:
|
|
778
|
-
"""Select the single
|
|
828
|
+
"""Select the single slug-matching resource from a connector lookup.
|
|
779
829
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
830
|
+
Like qurl-go's ``GetConnectorResourceBySlug``, validate the immutable
|
|
831
|
+
slug rather than the optional, mutable alias. The lookup must return
|
|
832
|
+
exactly one resource whose slug equals the connector id.
|
|
833
|
+
Raises the portal surface's client-detected errors (``status=0``)
|
|
783
834
|
otherwise — see :meth:`QURLClient.connector_resource` for the mapping.
|
|
784
835
|
|
|
785
836
|
ASSUMPTION: ``resources`` is the complete result set for the slug —
|
|
@@ -809,14 +860,14 @@ def require_connector_resource(
|
|
|
809
860
|
),
|
|
810
861
|
)
|
|
811
862
|
resource = resources[0]
|
|
812
|
-
if resource.
|
|
863
|
+
if resource.slug != connector_id:
|
|
813
864
|
raise ValidationError(
|
|
814
865
|
status=0,
|
|
815
866
|
code="unexpected_response",
|
|
816
867
|
title="Unexpected Response",
|
|
817
868
|
detail=(
|
|
818
869
|
f"connector_resource: connector {connector_id!r} returned a "
|
|
819
|
-
"resource with a missing or different
|
|
870
|
+
"resource with a missing or different slug"
|
|
820
871
|
),
|
|
821
872
|
)
|
|
822
873
|
return resource
|
|
@@ -873,8 +924,10 @@ def parse_portal(data: dict[str, Any]) -> Portal:
|
|
|
873
924
|
title="Unexpected Response",
|
|
874
925
|
detail="create portal: API response is missing resource_id or qurl_link",
|
|
875
926
|
)
|
|
927
|
+
validate_response_identity(data)
|
|
876
928
|
return Portal(
|
|
877
929
|
resource_id=resource_id,
|
|
930
|
+
crid=data.get("crid"),
|
|
878
931
|
link=qurl_link,
|
|
879
932
|
site=data.get("qurl_site"),
|
|
880
933
|
expires_at=_parse_dt(data.get("expires_at")),
|
|
@@ -989,8 +1042,10 @@ def _parse_rate_limits(data: dict[str, Any] | None) -> RateLimits | None:
|
|
|
989
1042
|
create_per_hour=data.get("create_per_hour", 0),
|
|
990
1043
|
list_per_minute=data.get("list_per_minute", 0),
|
|
991
1044
|
resolve_per_minute=data.get("resolve_per_minute", 0),
|
|
992
|
-
max_active_qurls=data.get("max_active_qurls"
|
|
1045
|
+
max_active_qurls=data.get("max_active_qurls"),
|
|
993
1046
|
max_expiry_seconds=data.get("max_expiry_seconds", 0),
|
|
1047
|
+
max_active_resources=data.get("max_active_resources"),
|
|
1048
|
+
max_data_transfer_bytes=data.get("max_data_transfer_bytes"),
|
|
994
1049
|
)
|
|
995
1050
|
|
|
996
1051
|
|
|
@@ -999,11 +1054,14 @@ def _parse_usage_block(data: dict[str, Any] | None) -> Usage | None:
|
|
|
999
1054
|
return None
|
|
1000
1055
|
return Usage(
|
|
1001
1056
|
qurls_created=data.get("qurls_created", 0),
|
|
1002
|
-
active_qurls=data.get("active_qurls"
|
|
1057
|
+
active_qurls=data.get("active_qurls"),
|
|
1003
1058
|
# Nullable per the API spec — the field is null when
|
|
1004
1059
|
# max_active_qurls is unlimited.
|
|
1005
1060
|
active_qurls_percent=data.get("active_qurls_percent"),
|
|
1006
1061
|
total_accesses=data.get("total_accesses", 0),
|
|
1062
|
+
active_resources=data.get("active_resources"),
|
|
1063
|
+
active_resources_percent=data.get("active_resources_percent"),
|
|
1064
|
+
data_transfer_bytes=data.get("data_transfer_bytes"),
|
|
1007
1065
|
)
|
|
1008
1066
|
|
|
1009
1067
|
|
|
@@ -1017,8 +1075,23 @@ def parse_list_output(data: Any, meta: dict[str, Any] | None) -> ListOutput:
|
|
|
1017
1075
|
def _parse_resource(data: dict[str, Any], *, strict_identity: bool) -> Resource:
|
|
1018
1076
|
resource_id = data["resource_id"] if strict_identity else data.get("resource_id", "")
|
|
1019
1077
|
status = data["status"] if strict_identity else data.get("status", "unknown")
|
|
1078
|
+
validate_response_identity(data)
|
|
1079
|
+
private = data.get("private")
|
|
1080
|
+
access_requests = data.get("access_requests")
|
|
1081
|
+
for value in (private, access_requests):
|
|
1082
|
+
if value is not None and not isinstance(value, bool):
|
|
1083
|
+
raise unexpected_response("invalid resource privacy or access_requests")
|
|
1084
|
+
device_keys = data.get("allowed_device_keys")
|
|
1085
|
+
if device_keys is not None and (
|
|
1086
|
+
not isinstance(device_keys, list) or any(not isinstance(key, str) for key in device_keys)
|
|
1087
|
+
):
|
|
1088
|
+
raise unexpected_response("invalid resource allowed_device_keys")
|
|
1020
1089
|
return Resource(
|
|
1021
1090
|
resource_id=resource_id,
|
|
1091
|
+
crid=data.get("crid"),
|
|
1092
|
+
private=private,
|
|
1093
|
+
access_requests=access_requests,
|
|
1094
|
+
allowed_device_keys=device_keys,
|
|
1022
1095
|
status=status,
|
|
1023
1096
|
resource_type=data.get("type"),
|
|
1024
1097
|
target_url=data.get("target_url"),
|
|
@@ -1216,20 +1289,25 @@ def parse_webhook_event_types_output(data: Any) -> WebhookEventTypesOutput:
|
|
|
1216
1289
|
return WebhookEventTypesOutput(events=events)
|
|
1217
1290
|
|
|
1218
1291
|
|
|
1292
|
+
def _parse_credential_claim(data: dict[str, Any]) -> CredentialClaim:
|
|
1293
|
+
return CredentialClaim(type=data.get("type", ""), id=data.get("id", ""))
|
|
1294
|
+
|
|
1295
|
+
|
|
1219
1296
|
def parse_api_key(data: dict[str, Any]) -> APIKey:
|
|
1220
|
-
"""Parse
|
|
1297
|
+
"""Parse credential metadata."""
|
|
1221
1298
|
return APIKey(
|
|
1222
1299
|
key_id=data["key_id"],
|
|
1223
1300
|
key_prefix=data["key_prefix"],
|
|
1224
1301
|
name=data.get("name", ""),
|
|
1302
|
+
kind=data.get("kind"),
|
|
1225
1303
|
scopes=data.get("scopes") or [],
|
|
1226
1304
|
status=data.get("status"),
|
|
1227
1305
|
created_at=_parse_dt(data.get("created_at")),
|
|
1228
1306
|
updated_at=_parse_dt(data.get("updated_at")),
|
|
1229
1307
|
last_used_at=_parse_dt(data.get("last_used_at")),
|
|
1230
1308
|
expires_at=_parse_dt(data.get("expires_at")),
|
|
1231
|
-
|
|
1232
|
-
|
|
1309
|
+
target=data.get("target"),
|
|
1310
|
+
claims=_parse_list_items(data.get("claims"), _parse_credential_claim),
|
|
1233
1311
|
api_key=data.get("api_key"),
|
|
1234
1312
|
)
|
|
1235
1313
|
|
|
@@ -1264,12 +1342,15 @@ def parse_access_code(data: dict[str, Any]) -> AccessCode:
|
|
|
1264
1342
|
def parse_access_code_list_output(
|
|
1265
1343
|
data: Any, meta: dict[str, Any] | None
|
|
1266
1344
|
) -> AccessCodeListOutput:
|
|
1267
|
-
"""
|
|
1345
|
+
"""Require the complete, unpaginated access-code contract."""
|
|
1268
1346
|
next_cursor, has_more = _meta_page(meta)
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1347
|
+
if next_cursor or has_more:
|
|
1348
|
+
raise QURLError(
|
|
1349
|
+
status=0,
|
|
1350
|
+
code="unexpected_response",
|
|
1351
|
+
title="Access-code listing unexpectedly returned pagination metadata",
|
|
1352
|
+
)
|
|
1353
|
+
return AccessCodeListOutput(access_codes=_parse_list_items(data, parse_access_code))
|
|
1273
1354
|
|
|
1274
1355
|
|
|
1275
1356
|
def _parse_usage_cost(data: dict[str, Any] | None) -> UsageCostEstimate | None:
|
|
@@ -1289,8 +1370,10 @@ def parse_current_period_usage(data: dict[str, Any]) -> CurrentPeriodUsage:
|
|
|
1289
1370
|
period_start=_parse_dt(data.get("period_start")),
|
|
1290
1371
|
period_end=_parse_dt(data.get("period_end")),
|
|
1291
1372
|
qurls_created=data.get("qurls_created", 0),
|
|
1292
|
-
active_qurls=data.get("active_qurls"
|
|
1373
|
+
active_qurls=data.get("active_qurls"),
|
|
1293
1374
|
cost_estimate=_parse_usage_cost(data.get("cost_estimate")),
|
|
1375
|
+
active_resources=data.get("active_resources"),
|
|
1376
|
+
data_transfer_bytes=data.get("data_transfer_bytes"),
|
|
1294
1377
|
)
|
|
1295
1378
|
|
|
1296
1379
|
|
|
@@ -1639,6 +1722,13 @@ def parse_error(response: httpx.Response) -> QURLError:
|
|
|
1639
1722
|
)
|
|
1640
1723
|
|
|
1641
1724
|
|
|
1725
|
+
def should_retry_transport(method: str, error: httpx.TransportError) -> bool:
|
|
1726
|
+
"""Only replay POST/PATCH before sending; retain idempotent method retries."""
|
|
1727
|
+
return method not in {"POST", "PATCH"} or isinstance(
|
|
1728
|
+
error, (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
|
|
1729
|
+
)
|
|
1730
|
+
|
|
1731
|
+
|
|
1642
1732
|
def retry_delay(attempt: int, last_error: Exception | None) -> float:
|
|
1643
1733
|
"""Compute retry delay with exponential backoff, jitter, and Retry-After cap."""
|
|
1644
1734
|
if isinstance(last_error, QURLError) and last_error.retry_after:
|
|
@@ -1699,3 +1789,142 @@ def mask_key(api_key: str) -> str:
|
|
|
1699
1789
|
if len(api_key) > 8:
|
|
1700
1790
|
return api_key[:4] + "***" + api_key[-4:]
|
|
1701
1791
|
return "***"
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def validate_optional_bool(value: bool | None, name: str) -> None:
|
|
1795
|
+
if value is not None and not isinstance(value, bool):
|
|
1796
|
+
raise ValueError(f"{name}: must be a bool")
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def check_response_crid(data: dict[str, Any], expected: str) -> None:
|
|
1800
|
+
if data.get("crid") != expected:
|
|
1801
|
+
raise unexpected_response("resource CRID is missing or differs from request")
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def build_share_body(ttl: timedelta | None, session_duration: timedelta | None) -> dict[str, Any]:
|
|
1805
|
+
body: dict[str, Any] = {}
|
|
1806
|
+
for name, value in (("ttl", ttl), ("session_duration", session_duration)):
|
|
1807
|
+
if value is None:
|
|
1808
|
+
continue
|
|
1809
|
+
if not isinstance(value, timedelta) or value < timedelta(0) or value.microseconds:
|
|
1810
|
+
raise ValueError(f"{name}: must be a nonnegative timedelta of whole seconds")
|
|
1811
|
+
seconds = value.days * 86400 + value.seconds
|
|
1812
|
+
if seconds:
|
|
1813
|
+
if name == "ttl":
|
|
1814
|
+
body["ttl_seconds"] = seconds
|
|
1815
|
+
else:
|
|
1816
|
+
body["session_duration"] = f"{seconds}s"
|
|
1817
|
+
return body
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
def parse_share_link(data: dict[str, Any], expected: str) -> ShareLink:
|
|
1821
|
+
if not isinstance(data, dict):
|
|
1822
|
+
raise unexpected_response("share response must be an object")
|
|
1823
|
+
check_response_crid(data, expected)
|
|
1824
|
+
for field_name, expected_type in (
|
|
1825
|
+
("qurl_id", str), ("type", str), ("expires_in_seconds", int), ("single_use", bool)
|
|
1826
|
+
):
|
|
1827
|
+
value = data.get(field_name)
|
|
1828
|
+
if value is not None and type(value) is not expected_type:
|
|
1829
|
+
raise unexpected_response(f"invalid share field: {field_name}")
|
|
1830
|
+
if (data.get("expires_in_seconds") or 0) < 0:
|
|
1831
|
+
raise unexpected_response("negative share expires_in_seconds")
|
|
1832
|
+
link = data.get("qurl")
|
|
1833
|
+
if not isinstance(link, str) or not link.strip():
|
|
1834
|
+
raise unexpected_response("share response is missing qurl")
|
|
1835
|
+
publisher = data.get("publisher")
|
|
1836
|
+
if publisher is None:
|
|
1837
|
+
publisher = {}
|
|
1838
|
+
if not isinstance(publisher, dict):
|
|
1839
|
+
raise unexpected_response("invalid publisher metadata")
|
|
1840
|
+
name = publisher.get("name")
|
|
1841
|
+
if name is None:
|
|
1842
|
+
name = ""
|
|
1843
|
+
verified = publisher.get("verified")
|
|
1844
|
+
if not isinstance(name, str) or (verified is not None and not isinstance(verified, bool)):
|
|
1845
|
+
raise unexpected_response("invalid publisher metadata")
|
|
1846
|
+
return ShareLink(
|
|
1847
|
+
link=link, crid=expected, qurl_id=data.get("qurl_id") or None,
|
|
1848
|
+
type=data.get("type"), expires_at=parse_share_timestamp(data.get("expires_at")),
|
|
1849
|
+
expires_in_seconds=data.get("expires_in_seconds") or 0,
|
|
1850
|
+
single_use=data.get("single_use") is True,
|
|
1851
|
+
resource_created_at=parse_share_timestamp(data.get("resource_created_at")),
|
|
1852
|
+
publisher=Publisher(name=name, verified=verified is True),
|
|
1853
|
+
)
|
|
1854
|
+
|
|
1855
|
+
|
|
1856
|
+
def build_device_grants(
|
|
1857
|
+
*, allowed_device_keys: Iterable[str] | None = None,
|
|
1858
|
+
allowed_device_keys_add: Iterable[str] | None = None,
|
|
1859
|
+
allowed_device_keys_remove: Iterable[str] | None = None,
|
|
1860
|
+
custom_domain: str | None = None,
|
|
1861
|
+
) -> dict[str, Any]:
|
|
1862
|
+
"""Preserve explicit empty lists; validate the API's two grant update forms."""
|
|
1863
|
+
body: dict[str, Any] = {}
|
|
1864
|
+
for name, value in (("allowed_device_keys", allowed_device_keys),
|
|
1865
|
+
("allowed_device_keys_add", allowed_device_keys_add),
|
|
1866
|
+
("allowed_device_keys_remove", allowed_device_keys_remove)):
|
|
1867
|
+
if value is None:
|
|
1868
|
+
continue
|
|
1869
|
+
keys = build_string_list(value, name, allow_empty=True)
|
|
1870
|
+
if len(keys) > 256 or len(set(keys)) != len(keys):
|
|
1871
|
+
raise ValueError(f"{name}: at most 256 unique keys are allowed")
|
|
1872
|
+
for key in keys:
|
|
1873
|
+
try:
|
|
1874
|
+
decoded = base64.b64decode(key, validate=True)
|
|
1875
|
+
except ValueError:
|
|
1876
|
+
raise ValueError(
|
|
1877
|
+
f"{name}: keys must be canonical base64 X25519 public keys"
|
|
1878
|
+
) from None
|
|
1879
|
+
if len(decoded) != 32 or base64.b64encode(decoded).decode() != key:
|
|
1880
|
+
raise ValueError(f"{name}: keys must be canonical base64 X25519 public keys")
|
|
1881
|
+
body[name] = keys
|
|
1882
|
+
if "allowed_device_keys" in body and len(body) > 1:
|
|
1883
|
+
raise ValueError("allowed_device_keys cannot be combined with add/remove")
|
|
1884
|
+
if body and custom_domain is not None:
|
|
1885
|
+
raise ValueError("device grants and custom_domain must be updated separately")
|
|
1886
|
+
added = set(body.get("allowed_device_keys_add", []))
|
|
1887
|
+
if added & set(body.get("allowed_device_keys_remove", [])):
|
|
1888
|
+
raise ValueError("a device key cannot be added and removed together")
|
|
1889
|
+
return body
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
def unexpected_response(detail: str) -> ValidationError:
|
|
1893
|
+
return ValidationError(status=0, code="unexpected_response", title="Unexpected Response",
|
|
1894
|
+
detail=detail)
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
def validate_response_identity(data: dict[str, Any]) -> None:
|
|
1898
|
+
"""Bind a reported CRID to its resource key, matching Go's resource parser.
|
|
1899
|
+
|
|
1900
|
+
Older responses omit CRID; REST compatibility callers can still use their
|
|
1901
|
+
resource_id. A reported CRID must never be accepted without its key binding.
|
|
1902
|
+
"""
|
|
1903
|
+
crid = data.get("crid")
|
|
1904
|
+
if crid is None:
|
|
1905
|
+
return
|
|
1906
|
+
key = data.get("resource_id")
|
|
1907
|
+
if not isinstance(crid, str) or not isinstance(key, str):
|
|
1908
|
+
raise unexpected_response("invalid resource identity")
|
|
1909
|
+
try:
|
|
1910
|
+
der = base64.b64decode(key + "=" * (-len(key) % 4), altchars=b"-_", validate=True)
|
|
1911
|
+
canonical = base64.urlsafe_b64encode(der).decode().rstrip("=") == key
|
|
1912
|
+
if not canonical or not key_matches(crid, der):
|
|
1913
|
+
raise ValueError("key binding")
|
|
1914
|
+
except ValueError:
|
|
1915
|
+
raise unexpected_response("invalid resource key-bound CRID") from None
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
def parse_share_timestamp(value: Any) -> datetime | None:
|
|
1919
|
+
if value is None:
|
|
1920
|
+
return None
|
|
1921
|
+
if not isinstance(value, str) or not re.fullmatch(
|
|
1922
|
+
r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}"
|
|
1923
|
+
r"(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})", value
|
|
1924
|
+
):
|
|
1925
|
+
raise unexpected_response("invalid share timestamp")
|
|
1926
|
+
try:
|
|
1927
|
+
parsed = _parse_dt(value)
|
|
1928
|
+
except ValueError:
|
|
1929
|
+
raise unexpected_response("invalid share timestamp") from None
|
|
1930
|
+
return None if value == "0001-01-01T00:00:00Z" else parsed
|