authpi-admin 0.3.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.
- authpi_admin/__init__.py +55 -0
- authpi_admin/_version.py +5 -0
- authpi_admin/client.py +155 -0
- authpi_admin/errors.py +218 -0
- authpi_admin/generated/__init__.py +28 -0
- authpi_admin/generated/models.py +380 -0
- authpi_admin/generated/resources/__init__.py +0 -0
- authpi_admin/generated/resources/accounts.py +157 -0
- authpi_admin/generated/resources/agents.py +177 -0
- authpi_admin/generated/resources/api_keys.py +213 -0
- authpi_admin/generated/resources/approvals.py +109 -0
- authpi_admin/generated/resources/auth_methods.py +148 -0
- authpi_admin/generated/resources/clients.py +197 -0
- authpi_admin/generated/resources/deliveries.py +83 -0
- authpi_admin/generated/resources/domains.py +194 -0
- authpi_admin/generated/resources/events.py +111 -0
- authpi_admin/generated/resources/groups.py +160 -0
- authpi_admin/generated/resources/invitations.py +202 -0
- authpi_admin/generated/resources/issuers.py +157 -0
- authpi_admin/generated/resources/members.py +154 -0
- authpi_admin/generated/resources/notes.py +192 -0
- authpi_admin/generated/resources/organizations.py +244 -0
- authpi_admin/generated/resources/sessions.py +125 -0
- authpi_admin/generated/resources/tokens.py +94 -0
- authpi_admin/generated/resources/trusted_devices.py +102 -0
- authpi_admin/generated/resources/users.py +224 -0
- authpi_admin/generated/resources/verifiers.py +102 -0
- authpi_admin/generated/resources/webhooks.py +167 -0
- authpi_admin/generated/scopes/__init__.py +0 -0
- authpi_admin/generated/scopes/agent_scope.py +84 -0
- authpi_admin/generated/scopes/issuer_scope.py +160 -0
- authpi_admin/generated/scopes/user_scope.py +102 -0
- authpi_admin/generated/scopes/webhook_scope.py +84 -0
- authpi_admin/http_client.py +305 -0
- authpi_admin/pagination.py +41 -0
- authpi_admin/py.typed +0 -0
- authpi_admin/user_agent.py +21 -0
- authpi_admin-0.3.0.dist-info/METADATA +285 -0
- authpi_admin-0.3.0.dist-info/RECORD +40 -0
- authpi_admin-0.3.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Generated scope class for IssuerScope — DO NOT EDIT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import Any, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from authpi_admin.http_client import validate_path_segment as _validate_path_segment
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from authpi_admin.http_client import HttpClient
|
|
12
|
+
from authpi_admin.generated.resources.agents import AgentsResource
|
|
13
|
+
from authpi_admin.generated.resources.approvals import ApprovalsResource
|
|
14
|
+
from authpi_admin.generated.resources.auth_methods import AuthMethodsResource
|
|
15
|
+
from authpi_admin.generated.resources.clients import ClientsResource
|
|
16
|
+
from authpi_admin.generated.resources.organizations import OrganizationsResource
|
|
17
|
+
from authpi_admin.generated.resources.api_keys import ApiKeysResource
|
|
18
|
+
from authpi_admin.generated.resources.groups import GroupsResource
|
|
19
|
+
from authpi_admin.generated.resources.invitations import InvitationsResource
|
|
20
|
+
from authpi_admin.generated.resources.members import MembersResource
|
|
21
|
+
from authpi_admin.generated.resources.domains import DomainsResource
|
|
22
|
+
from authpi_admin.generated.resources.sessions import SessionsResource
|
|
23
|
+
from authpi_admin.generated.resources.users import UsersResource
|
|
24
|
+
from authpi_admin.generated.scopes.agent_scope import AgentScope
|
|
25
|
+
from authpi_admin.generated.scopes.user_scope import UserScope
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IssuerScope:
|
|
29
|
+
"""Scope for operations under a specific issuer."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, client: HttpClient, base_path: str, issuer_id: str) -> None:
|
|
32
|
+
_validate_path_segment(issuer_id, "issuer_id")
|
|
33
|
+
self._client = client
|
|
34
|
+
self._base_path = base_path
|
|
35
|
+
self._issuer_id = issuer_id
|
|
36
|
+
self._path = f"{base_path}/{issuer_id}"
|
|
37
|
+
|
|
38
|
+
@cached_property
|
|
39
|
+
def agents(self) -> AgentsResource:
|
|
40
|
+
"""Access agents under this issuer."""
|
|
41
|
+
return AgentsResource(self._client, f"{self._path}/agents")
|
|
42
|
+
|
|
43
|
+
@cached_property
|
|
44
|
+
def approvals(self) -> ApprovalsResource:
|
|
45
|
+
"""Access approvals under this issuer."""
|
|
46
|
+
return ApprovalsResource(self._client, f"{self._path}/approvals")
|
|
47
|
+
|
|
48
|
+
@cached_property
|
|
49
|
+
def auth_methods(self) -> AuthMethodsResource:
|
|
50
|
+
"""Access auth_methods under this issuer."""
|
|
51
|
+
return AuthMethodsResource(self._client, f"{self._path}/auth-methods")
|
|
52
|
+
|
|
53
|
+
@cached_property
|
|
54
|
+
def clients(self) -> ClientsResource:
|
|
55
|
+
"""Access clients under this issuer."""
|
|
56
|
+
return ClientsResource(self._client, f"{self._path}/clients")
|
|
57
|
+
|
|
58
|
+
@cached_property
|
|
59
|
+
def organizations(self) -> OrganizationsResource:
|
|
60
|
+
"""Access organizations under this issuer."""
|
|
61
|
+
return OrganizationsResource(self._client, f"{self._path}/organizations")
|
|
62
|
+
|
|
63
|
+
@cached_property
|
|
64
|
+
def api_keys(self) -> ApiKeysResource:
|
|
65
|
+
"""Access api_keys under this issuer."""
|
|
66
|
+
return ApiKeysResource(self._client, f"{self._path}/api-keys")
|
|
67
|
+
|
|
68
|
+
@cached_property
|
|
69
|
+
def groups(self) -> GroupsResource:
|
|
70
|
+
"""Access groups under this issuer."""
|
|
71
|
+
return GroupsResource(self._client, f"{self._path}/groups")
|
|
72
|
+
|
|
73
|
+
@cached_property
|
|
74
|
+
def invitations(self) -> InvitationsResource:
|
|
75
|
+
"""Access invitations under this issuer."""
|
|
76
|
+
return InvitationsResource(self._client, f"{self._path}/invitations")
|
|
77
|
+
|
|
78
|
+
@cached_property
|
|
79
|
+
def members(self) -> MembersResource:
|
|
80
|
+
"""Access members under this issuer."""
|
|
81
|
+
return MembersResource(self._client, f"{self._path}/members")
|
|
82
|
+
|
|
83
|
+
@cached_property
|
|
84
|
+
def domains(self) -> DomainsResource:
|
|
85
|
+
"""Access domains under this issuer."""
|
|
86
|
+
return DomainsResource(self._client, f"{self._path}/domains")
|
|
87
|
+
|
|
88
|
+
@cached_property
|
|
89
|
+
def sessions(self) -> SessionsResource:
|
|
90
|
+
"""Access sessions under this issuer."""
|
|
91
|
+
return SessionsResource(self._client, f"{self._path}/sessions")
|
|
92
|
+
|
|
93
|
+
@cached_property
|
|
94
|
+
def users(self) -> UsersResource:
|
|
95
|
+
"""Access users under this issuer."""
|
|
96
|
+
return UsersResource(self._client, f"{self._path}/users")
|
|
97
|
+
|
|
98
|
+
def agent(self, agent_id: str) -> AgentScope:
|
|
99
|
+
"""Scope into a specific agent."""
|
|
100
|
+
return AgentScope(self._client, f"{self._path}/agents", agent_id)
|
|
101
|
+
|
|
102
|
+
def user(self, user_id: str) -> UserScope:
|
|
103
|
+
"""Scope into a specific user."""
|
|
104
|
+
return UserScope(self._client, f"{self._path}/users", user_id)
|
|
105
|
+
|
|
106
|
+
async def get(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
timeout: float | None = None,
|
|
110
|
+
headers: dict[str, str] | None = None,
|
|
111
|
+
) -> Any:
|
|
112
|
+
"""Get Issuer"""
|
|
113
|
+
resp = await self._client.request("GET", self._path, timeout=timeout, headers=headers)
|
|
114
|
+
return resp.data
|
|
115
|
+
|
|
116
|
+
async def update(
|
|
117
|
+
self,
|
|
118
|
+
body: dict[str, Any],
|
|
119
|
+
*,
|
|
120
|
+
if_match: str | None = None,
|
|
121
|
+
idempotency_key: str | None = None,
|
|
122
|
+
timeout: float | None = None,
|
|
123
|
+
headers: dict[str, str] | None = None,
|
|
124
|
+
) -> Any:
|
|
125
|
+
"""Update Issuer"""
|
|
126
|
+
req_headers: dict[str, str] = {}
|
|
127
|
+
if headers:
|
|
128
|
+
req_headers.update(headers)
|
|
129
|
+
if if_match:
|
|
130
|
+
req_headers["if-match"] = if_match
|
|
131
|
+
if idempotency_key:
|
|
132
|
+
req_headers["idempotency-key"] = idempotency_key
|
|
133
|
+
resp = await self._client.request(
|
|
134
|
+
"PATCH",
|
|
135
|
+
self._path,
|
|
136
|
+
body=body,
|
|
137
|
+
timeout=timeout,
|
|
138
|
+
headers=req_headers or None,
|
|
139
|
+
)
|
|
140
|
+
return resp.data
|
|
141
|
+
|
|
142
|
+
async def delete(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
if_match: str | None = None,
|
|
146
|
+
timeout: float | None = None,
|
|
147
|
+
headers: dict[str, str] | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Delete Issuer"""
|
|
150
|
+
req_headers: dict[str, str] = {}
|
|
151
|
+
if headers:
|
|
152
|
+
req_headers.update(headers)
|
|
153
|
+
if if_match:
|
|
154
|
+
req_headers["if-match"] = if_match
|
|
155
|
+
await self._client.request(
|
|
156
|
+
"DELETE",
|
|
157
|
+
self._path,
|
|
158
|
+
timeout=timeout,
|
|
159
|
+
headers=req_headers or None,
|
|
160
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Generated scope class for UserScope — DO NOT EDIT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import Any, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from authpi_admin.http_client import validate_path_segment as _validate_path_segment
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from authpi_admin.http_client import HttpClient
|
|
12
|
+
from authpi_admin.generated.resources.sessions import SessionsResource
|
|
13
|
+
from authpi_admin.generated.resources.tokens import TokensResource
|
|
14
|
+
from authpi_admin.generated.resources.trusted_devices import TrustedDevicesResource
|
|
15
|
+
from authpi_admin.generated.resources.verifiers import VerifiersResource
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UserScope:
|
|
19
|
+
"""Scope for operations under a specific user."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: HttpClient, base_path: str, user_id: str) -> None:
|
|
22
|
+
_validate_path_segment(user_id, "user_id")
|
|
23
|
+
self._client = client
|
|
24
|
+
self._base_path = base_path
|
|
25
|
+
self._user_id = user_id
|
|
26
|
+
self._path = f"{base_path}/{user_id}"
|
|
27
|
+
|
|
28
|
+
@cached_property
|
|
29
|
+
def sessions(self) -> SessionsResource:
|
|
30
|
+
"""Access sessions under this user."""
|
|
31
|
+
return SessionsResource(self._client, f"{self._path}/sessions")
|
|
32
|
+
|
|
33
|
+
@cached_property
|
|
34
|
+
def tokens(self) -> TokensResource:
|
|
35
|
+
"""Access tokens under this user."""
|
|
36
|
+
return TokensResource(self._client, f"{self._path}/tokens")
|
|
37
|
+
|
|
38
|
+
@cached_property
|
|
39
|
+
def trusted_devices(self) -> TrustedDevicesResource:
|
|
40
|
+
"""Access trusted_devices under this user."""
|
|
41
|
+
return TrustedDevicesResource(self._client, f"{self._path}/trusted-devices")
|
|
42
|
+
|
|
43
|
+
@cached_property
|
|
44
|
+
def verifiers(self) -> VerifiersResource:
|
|
45
|
+
"""Access verifiers under this user."""
|
|
46
|
+
return VerifiersResource(self._client, f"{self._path}/verifiers")
|
|
47
|
+
|
|
48
|
+
async def get(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
timeout: float | None = None,
|
|
52
|
+
headers: dict[str, str] | None = None,
|
|
53
|
+
) -> Any:
|
|
54
|
+
"""Get User"""
|
|
55
|
+
resp = await self._client.request("GET", self._path, timeout=timeout, headers=headers)
|
|
56
|
+
return resp.data
|
|
57
|
+
|
|
58
|
+
async def update(
|
|
59
|
+
self,
|
|
60
|
+
body: dict[str, Any],
|
|
61
|
+
*,
|
|
62
|
+
if_match: str | None = None,
|
|
63
|
+
idempotency_key: str | None = None,
|
|
64
|
+
timeout: float | None = None,
|
|
65
|
+
headers: dict[str, str] | None = None,
|
|
66
|
+
) -> Any:
|
|
67
|
+
"""Update User"""
|
|
68
|
+
req_headers: dict[str, str] = {}
|
|
69
|
+
if headers:
|
|
70
|
+
req_headers.update(headers)
|
|
71
|
+
if if_match:
|
|
72
|
+
req_headers["if-match"] = if_match
|
|
73
|
+
if idempotency_key:
|
|
74
|
+
req_headers["idempotency-key"] = idempotency_key
|
|
75
|
+
resp = await self._client.request(
|
|
76
|
+
"PATCH",
|
|
77
|
+
self._path,
|
|
78
|
+
body=body,
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
headers=req_headers or None,
|
|
81
|
+
)
|
|
82
|
+
return resp.data
|
|
83
|
+
|
|
84
|
+
async def delete(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
if_match: str | None = None,
|
|
88
|
+
timeout: float | None = None,
|
|
89
|
+
headers: dict[str, str] | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Delete User"""
|
|
92
|
+
req_headers: dict[str, str] = {}
|
|
93
|
+
if headers:
|
|
94
|
+
req_headers.update(headers)
|
|
95
|
+
if if_match:
|
|
96
|
+
req_headers["if-match"] = if_match
|
|
97
|
+
await self._client.request(
|
|
98
|
+
"DELETE",
|
|
99
|
+
self._path,
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
headers=req_headers or None,
|
|
102
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Generated scope class for WebhookScope — DO NOT EDIT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import Any, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from authpi_admin.http_client import validate_path_segment as _validate_path_segment
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from authpi_admin.http_client import HttpClient
|
|
12
|
+
from authpi_admin.generated.resources.deliveries import DeliveriesResource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WebhookScope:
|
|
16
|
+
"""Scope for operations under a specific webhook."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, client: HttpClient, base_path: str, webhook_id: str) -> None:
|
|
19
|
+
_validate_path_segment(webhook_id, "webhook_id")
|
|
20
|
+
self._client = client
|
|
21
|
+
self._base_path = base_path
|
|
22
|
+
self._webhook_id = webhook_id
|
|
23
|
+
self._path = f"{base_path}/{webhook_id}"
|
|
24
|
+
|
|
25
|
+
@cached_property
|
|
26
|
+
def deliveries(self) -> DeliveriesResource:
|
|
27
|
+
"""Access deliveries under this webhook."""
|
|
28
|
+
return DeliveriesResource(self._client, f"{self._path}/deliveries")
|
|
29
|
+
|
|
30
|
+
async def get(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
timeout: float | None = None,
|
|
34
|
+
headers: dict[str, str] | None = None,
|
|
35
|
+
) -> Any:
|
|
36
|
+
"""Get Webhook"""
|
|
37
|
+
resp = await self._client.request("GET", self._path, timeout=timeout, headers=headers)
|
|
38
|
+
return resp.data
|
|
39
|
+
|
|
40
|
+
async def update(
|
|
41
|
+
self,
|
|
42
|
+
body: dict[str, Any],
|
|
43
|
+
*,
|
|
44
|
+
if_match: str | None = None,
|
|
45
|
+
idempotency_key: str | None = None,
|
|
46
|
+
timeout: float | None = None,
|
|
47
|
+
headers: dict[str, str] | None = None,
|
|
48
|
+
) -> Any:
|
|
49
|
+
"""Update Webhook"""
|
|
50
|
+
req_headers: dict[str, str] = {}
|
|
51
|
+
if headers:
|
|
52
|
+
req_headers.update(headers)
|
|
53
|
+
if if_match:
|
|
54
|
+
req_headers["if-match"] = if_match
|
|
55
|
+
if idempotency_key:
|
|
56
|
+
req_headers["idempotency-key"] = idempotency_key
|
|
57
|
+
resp = await self._client.request(
|
|
58
|
+
"PATCH",
|
|
59
|
+
self._path,
|
|
60
|
+
body=body,
|
|
61
|
+
timeout=timeout,
|
|
62
|
+
headers=req_headers or None,
|
|
63
|
+
)
|
|
64
|
+
return resp.data
|
|
65
|
+
|
|
66
|
+
async def delete(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
if_match: str | None = None,
|
|
70
|
+
timeout: float | None = None,
|
|
71
|
+
headers: dict[str, str] | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Delete Webhook"""
|
|
74
|
+
req_headers: dict[str, str] = {}
|
|
75
|
+
if headers:
|
|
76
|
+
req_headers.update(headers)
|
|
77
|
+
if if_match:
|
|
78
|
+
req_headers["if-match"] = if_match
|
|
79
|
+
await self._client.request(
|
|
80
|
+
"DELETE",
|
|
81
|
+
self._path,
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
headers=req_headers or None,
|
|
84
|
+
)
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Internal HTTP client — not exported. Used by all resource classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Awaitable, Callable
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from authpi_admin.errors import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
ClosedClientError,
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
error_from_response,
|
|
17
|
+
)
|
|
18
|
+
from authpi_admin._version import SDK_VERSION
|
|
19
|
+
from authpi_admin.user_agent import build_user_agent
|
|
20
|
+
|
|
21
|
+
_USER_AGENT = build_user_agent(SDK_VERSION)
|
|
22
|
+
|
|
23
|
+
# Maximum raw body size to store on errors (4KB)
|
|
24
|
+
_MAX_RAW_BODY = 4096
|
|
25
|
+
|
|
26
|
+
_UNSAFE_PATH_CHARS = frozenset("/\\?#")
|
|
27
|
+
|
|
28
|
+
# Headers that must not be overridden by user-provided values.
|
|
29
|
+
_PROTECTED_HEADERS = frozenset({"authorization", "host", "user-agent"})
|
|
30
|
+
|
|
31
|
+
_RETRYABLE_STATUSES = frozenset({429, 502, 503, 504})
|
|
32
|
+
_RETRYABLE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_path_segment(value: str, name: str = "id") -> str:
|
|
36
|
+
"""Validate that a value is safe to interpolate into a URL path segment."""
|
|
37
|
+
if not value or any(c in value for c in _UNSAFE_PATH_CHARS) or value in (".", ".."):
|
|
38
|
+
raise ValueError(f"Invalid {name}: must not be empty or contain path separators")
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _strip_underscore_keys(obj: Any) -> Any:
|
|
43
|
+
"""Recursively remove keys starting with ``_`` from dicts (e.g. ``_etag``)."""
|
|
44
|
+
if obj is None or not isinstance(obj, (dict, list)):
|
|
45
|
+
return obj
|
|
46
|
+
if isinstance(obj, list):
|
|
47
|
+
return [_strip_underscore_keys(item) for item in obj]
|
|
48
|
+
return {k: _strip_underscore_keys(v) for k, v in obj.items() if not k.startswith("_")}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_retry_after(header: str | None) -> float | None:
|
|
52
|
+
"""Parse Retry-After header value into seconds."""
|
|
53
|
+
if not header:
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
return float(header)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class RetryConfig:
|
|
63
|
+
"""Retry configuration."""
|
|
64
|
+
|
|
65
|
+
limit: int = 3
|
|
66
|
+
delay: float = 1.0
|
|
67
|
+
backoff: str = "exponential" # "exponential" or "linear"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class ApiResponse:
|
|
72
|
+
"""Response wrapper carrying parsed data and optional ETag."""
|
|
73
|
+
|
|
74
|
+
data: Any
|
|
75
|
+
etag: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class HttpClient:
|
|
79
|
+
"""Low-level HTTP client that handles auth, serialization, retries, and error mapping."""
|
|
80
|
+
|
|
81
|
+
__slots__ = (
|
|
82
|
+
"_base_url",
|
|
83
|
+
"_HttpClient__credential",
|
|
84
|
+
"_owns_client",
|
|
85
|
+
"_closed",
|
|
86
|
+
"_client",
|
|
87
|
+
"_default_headers",
|
|
88
|
+
"_retry_config",
|
|
89
|
+
"_on_token_expired",
|
|
90
|
+
"_refresh_lock",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
base_url: str,
|
|
96
|
+
*,
|
|
97
|
+
api_key: str | None = None,
|
|
98
|
+
access_token: str | None = None,
|
|
99
|
+
on_token_expired: Callable[[], Awaitable[dict[str, str]]] | None = None,
|
|
100
|
+
http_client: httpx.AsyncClient | None = None,
|
|
101
|
+
timeout: float = 30.0,
|
|
102
|
+
default_headers: dict[str, str] | None = None,
|
|
103
|
+
retries: bool | dict[str, Any] | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
has_api_key = api_key is not None
|
|
106
|
+
has_access_token = access_token is not None
|
|
107
|
+
|
|
108
|
+
if not has_api_key and not has_access_token:
|
|
109
|
+
raise ConfigurationError("Either api_key or access_token is required")
|
|
110
|
+
if has_api_key and has_access_token:
|
|
111
|
+
raise ConfigurationError("Provide either api_key or access_token, not both")
|
|
112
|
+
|
|
113
|
+
self._base_url = base_url.rstrip("/")
|
|
114
|
+
self.__credential = api_key or access_token # name-mangled
|
|
115
|
+
self._default_headers = default_headers or {}
|
|
116
|
+
self._on_token_expired = on_token_expired
|
|
117
|
+
self._refresh_lock = asyncio.Lock()
|
|
118
|
+
self._owns_client = http_client is None
|
|
119
|
+
self._closed = False
|
|
120
|
+
|
|
121
|
+
if retries is False:
|
|
122
|
+
self._retry_config: RetryConfig | None = None
|
|
123
|
+
else:
|
|
124
|
+
r = retries if isinstance(retries, dict) else {}
|
|
125
|
+
self._retry_config = RetryConfig(
|
|
126
|
+
limit=r.get("limit", 3),
|
|
127
|
+
delay=r.get("delay", 1.0),
|
|
128
|
+
backoff=r.get("backoff", "exponential"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if http_client is not None:
|
|
132
|
+
self._client = http_client
|
|
133
|
+
else:
|
|
134
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
135
|
+
|
|
136
|
+
def _update_credential(self, token: str) -> None:
|
|
137
|
+
"""Update the bearer credential after a token refresh."""
|
|
138
|
+
self.__credential = token
|
|
139
|
+
|
|
140
|
+
def __repr__(self) -> str:
|
|
141
|
+
return f"HttpClient(base_url={self._base_url!r})"
|
|
142
|
+
|
|
143
|
+
async def request(
|
|
144
|
+
self,
|
|
145
|
+
method: str,
|
|
146
|
+
path: str,
|
|
147
|
+
*,
|
|
148
|
+
body: Any = None,
|
|
149
|
+
query: dict[str, Any] | None = None,
|
|
150
|
+
headers: dict[str, str] | None = None,
|
|
151
|
+
timeout: float | None = None,
|
|
152
|
+
) -> ApiResponse:
|
|
153
|
+
"""Send an HTTP request and return the parsed response."""
|
|
154
|
+
return await self._request(method, path, body=body, query=query, headers=headers, timeout=timeout, is_refresh_retry=False)
|
|
155
|
+
|
|
156
|
+
async def _request(
|
|
157
|
+
self,
|
|
158
|
+
method: str,
|
|
159
|
+
path: str,
|
|
160
|
+
*,
|
|
161
|
+
body: Any = None,
|
|
162
|
+
query: dict[str, Any] | None = None,
|
|
163
|
+
headers: dict[str, str] | None = None,
|
|
164
|
+
timeout: float | None = None,
|
|
165
|
+
is_refresh_retry: bool = False,
|
|
166
|
+
) -> ApiResponse:
|
|
167
|
+
if self._closed:
|
|
168
|
+
raise ClosedClientError()
|
|
169
|
+
|
|
170
|
+
url = f"{self._base_url}{path}"
|
|
171
|
+
|
|
172
|
+
filtered_query: dict[str, Any] | None = None
|
|
173
|
+
if query:
|
|
174
|
+
filtered_query = {k: v for k, v in query.items() if v is not None}
|
|
175
|
+
|
|
176
|
+
content = json.dumps(_strip_underscore_keys(body)).encode() if body is not None else None
|
|
177
|
+
|
|
178
|
+
can_retry = self._retry_config is not None and method.upper() in _RETRYABLE_METHODS
|
|
179
|
+
max_attempts = (self._retry_config.limit + 1) if can_retry and self._retry_config else 1
|
|
180
|
+
|
|
181
|
+
for attempt in range(max_attempts):
|
|
182
|
+
req_headers = self._build_headers(body is not None, headers)
|
|
183
|
+
|
|
184
|
+
kwargs: dict[str, Any] = {
|
|
185
|
+
"method": method,
|
|
186
|
+
"url": url,
|
|
187
|
+
"headers": req_headers,
|
|
188
|
+
}
|
|
189
|
+
if content is not None:
|
|
190
|
+
kwargs["content"] = content
|
|
191
|
+
if filtered_query:
|
|
192
|
+
kwargs["params"] = filtered_query
|
|
193
|
+
if timeout is not None:
|
|
194
|
+
kwargs["timeout"] = timeout
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
response = await self._client.request(**kwargs)
|
|
198
|
+
except httpx.CloseError as err:
|
|
199
|
+
raise ClosedClientError() from err
|
|
200
|
+
except RuntimeError as err:
|
|
201
|
+
if "closed" in str(err).lower():
|
|
202
|
+
raise ClosedClientError() from err
|
|
203
|
+
raise
|
|
204
|
+
|
|
205
|
+
# Handle 401 with token refresh
|
|
206
|
+
if response.status_code == 401 and self._on_token_expired and not is_refresh_retry:
|
|
207
|
+
await self._singleflight_refresh(self.__credential)
|
|
208
|
+
return await self._request(
|
|
209
|
+
method, path, body=body, query=query, headers=headers,
|
|
210
|
+
timeout=timeout, is_refresh_retry=True,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if response.is_success:
|
|
214
|
+
return self._handle_success(response)
|
|
215
|
+
|
|
216
|
+
# Check if we should retry
|
|
217
|
+
if can_retry and response.status_code in _RETRYABLE_STATUSES and attempt < max_attempts - 1:
|
|
218
|
+
retry_after = _parse_retry_after(response.headers.get("retry-after"))
|
|
219
|
+
if retry_after is not None:
|
|
220
|
+
delay = retry_after
|
|
221
|
+
elif self._retry_config and self._retry_config.backoff == "exponential":
|
|
222
|
+
delay = self._retry_config.delay * (2 ** attempt)
|
|
223
|
+
elif self._retry_config:
|
|
224
|
+
delay = self._retry_config.delay * (attempt + 1)
|
|
225
|
+
else:
|
|
226
|
+
delay = 1.0
|
|
227
|
+
await asyncio.sleep(delay)
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
# Not retryable or last attempt
|
|
231
|
+
self._handle_error(response)
|
|
232
|
+
|
|
233
|
+
# Unreachable
|
|
234
|
+
raise RuntimeError("unreachable")
|
|
235
|
+
|
|
236
|
+
async def _singleflight_refresh(self, stale_credential: str) -> str:
|
|
237
|
+
"""Deduplicate concurrent token refresh calls.
|
|
238
|
+
|
|
239
|
+
Uses the lock + credential comparison pattern: if the credential has
|
|
240
|
+
already been updated by the time we acquire the lock, skip the refresh.
|
|
241
|
+
This prevents rotating refresh tokens from being consumed multiple
|
|
242
|
+
times under concurrency.
|
|
243
|
+
"""
|
|
244
|
+
async with self._refresh_lock:
|
|
245
|
+
# If credential changed while we waited, another coroutine already refreshed
|
|
246
|
+
if self.__credential != stale_credential:
|
|
247
|
+
return self.__credential # type: ignore[return-value]
|
|
248
|
+
|
|
249
|
+
assert self._on_token_expired is not None
|
|
250
|
+
result = await self._on_token_expired()
|
|
251
|
+
self._update_credential(result["access_token"])
|
|
252
|
+
return result["access_token"]
|
|
253
|
+
|
|
254
|
+
def _build_headers(self, has_body: bool, extra: dict[str, str] | None) -> dict[str, str]:
|
|
255
|
+
req_headers: dict[str, str] = {
|
|
256
|
+
"authorization": f"Bearer {self.__credential}",
|
|
257
|
+
"accept": "application/json",
|
|
258
|
+
"user-agent": _USER_AGENT,
|
|
259
|
+
}
|
|
260
|
+
if has_body:
|
|
261
|
+
req_headers["content-type"] = "application/json"
|
|
262
|
+
|
|
263
|
+
for key, value in self._default_headers.items():
|
|
264
|
+
if key.lower() not in _PROTECTED_HEADERS:
|
|
265
|
+
req_headers[key] = value
|
|
266
|
+
|
|
267
|
+
if extra:
|
|
268
|
+
for key, value in extra.items():
|
|
269
|
+
if key.lower() not in _PROTECTED_HEADERS:
|
|
270
|
+
req_headers[key] = value
|
|
271
|
+
|
|
272
|
+
return req_headers
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _handle_success(response: httpx.Response) -> ApiResponse:
|
|
276
|
+
if response.status_code == 204 or not response.content:
|
|
277
|
+
return ApiResponse(data=None)
|
|
278
|
+
data = response.json()
|
|
279
|
+
etag = response.headers.get("etag")
|
|
280
|
+
return ApiResponse(data=data, etag=etag)
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _handle_error(response: httpx.Response) -> None:
|
|
284
|
+
raw_body: str | None = None
|
|
285
|
+
parsed_body: dict[str, Any] | None = None
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
parsed_body = response.json()
|
|
289
|
+
except Exception:
|
|
290
|
+
raw_text = response.text
|
|
291
|
+
raw_body = raw_text[:_MAX_RAW_BODY] if raw_text else None
|
|
292
|
+
|
|
293
|
+
resp_headers = dict(response.headers)
|
|
294
|
+
raise error_from_response(
|
|
295
|
+
status_code=response.status_code,
|
|
296
|
+
body=parsed_body,
|
|
297
|
+
headers=resp_headers,
|
|
298
|
+
raw_body=raw_body,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
async def close(self) -> None:
|
|
302
|
+
"""Close the underlying HTTP client (only if owned by this instance)."""
|
|
303
|
+
if self._owns_client:
|
|
304
|
+
await self._client.aclose()
|
|
305
|
+
self._closed = self._owns_client
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Cursor-based pagination helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Page(Generic[T]):
|
|
14
|
+
"""A single page of results from a paginated endpoint."""
|
|
15
|
+
|
|
16
|
+
data: list[T]
|
|
17
|
+
has_more: bool
|
|
18
|
+
next_cursor: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def auto_paginate(
|
|
22
|
+
fetcher: Callable[[str | None], Awaitable[Page[T]]],
|
|
23
|
+
) -> AsyncIterator[T]:
|
|
24
|
+
"""Automatically paginate through all results.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
fetcher: An async function that takes an optional cursor and returns a Page.
|
|
28
|
+
|
|
29
|
+
Yields:
|
|
30
|
+
Individual items from each page.
|
|
31
|
+
"""
|
|
32
|
+
cursor: str | None = None
|
|
33
|
+
while True:
|
|
34
|
+
page = await fetcher(cursor)
|
|
35
|
+
for item in page.data:
|
|
36
|
+
yield item
|
|
37
|
+
if not page.has_more:
|
|
38
|
+
break
|
|
39
|
+
cursor = page.next_cursor
|
|
40
|
+
if cursor is None:
|
|
41
|
+
break
|
authpi_admin/py.typed
ADDED
|
File without changes
|