xAPI-client 1.0.11__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.
- xapi/__init__.py +63 -0
- xapi/apiKey.py +90 -0
- xapi/client.py +286 -0
- xapi/endpoint.py +128 -0
- xapi/exceptions.py +221 -0
- xapi/logger.py +115 -0
- xapi/model.py +120 -0
- xapi/options.py +31 -0
- xapi/path.py +89 -0
- xapi/query.py +90 -0
- xapi/rateLimit.py +37 -0
- xapi/resource.py +178 -0
- xapi/retry.py +35 -0
- xapi/type.py +46 -0
- xapi/url.py +55 -0
- xapi/util.py +116 -0
- xapi_client-1.0.11.dist-info/METADATA +625 -0
- xapi_client-1.0.11.dist-info/RECORD +21 -0
- xapi_client-1.0.11.dist-info/WHEEL +5 -0
- xapi_client-1.0.11.dist-info/licenses/LICENSE.md +12 -0
- xapi_client-1.0.11.dist-info/top_level.txt +1 -0
xapi/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from .apiKey import APIKey
|
|
2
|
+
from .client import Client
|
|
3
|
+
from .endpoint import Endpoint
|
|
4
|
+
from .path import Path, Parameters
|
|
5
|
+
from .rateLimit import RateLimiter
|
|
6
|
+
from .resource import Resource
|
|
7
|
+
from .retry import RetryConfig
|
|
8
|
+
from .type import NotGiven, NotGivenOr, NOTGIVEN
|
|
9
|
+
from .options import Options, IntOptions
|
|
10
|
+
from .model import ResponseModel, ResponseModelList, ResponseModels
|
|
11
|
+
from .query import Query
|
|
12
|
+
from .logger import logger
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
APIError,
|
|
15
|
+
APIStatusError,
|
|
16
|
+
APIResponseValidationError,
|
|
17
|
+
APIConnectionError,
|
|
18
|
+
APITimeoutError,
|
|
19
|
+
BadRequestError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
PermissionDeniedError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
ConflictError,
|
|
24
|
+
UnprocessableEntityError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
InternalServerError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger.configure()
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Client",
|
|
33
|
+
"Resource",
|
|
34
|
+
"Endpoint",
|
|
35
|
+
"APIKey",
|
|
36
|
+
"RetryConfig",
|
|
37
|
+
"RateLimiter",
|
|
38
|
+
"Path",
|
|
39
|
+
"Parameters",
|
|
40
|
+
"NotGiven",
|
|
41
|
+
"NotGivenOr",
|
|
42
|
+
"NOTGIVEN",
|
|
43
|
+
"Options",
|
|
44
|
+
"IntOptions",
|
|
45
|
+
"ResponseModel",
|
|
46
|
+
"ResponseModelList",
|
|
47
|
+
"ResponseModels",
|
|
48
|
+
"Query",
|
|
49
|
+
"logger",
|
|
50
|
+
"APIError",
|
|
51
|
+
"APIStatusError",
|
|
52
|
+
"APIResponseValidationError",
|
|
53
|
+
"APIConnectionError",
|
|
54
|
+
"APITimeoutError",
|
|
55
|
+
"BadRequestError",
|
|
56
|
+
"AuthenticationError",
|
|
57
|
+
"PermissionDeniedError",
|
|
58
|
+
"NotFoundError",
|
|
59
|
+
"ConflictError",
|
|
60
|
+
"UnprocessableEntityError",
|
|
61
|
+
"RateLimitError",
|
|
62
|
+
"InternalServerError",
|
|
63
|
+
]
|
xapi/apiKey.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from .query import Query
|
|
5
|
+
from .type import Headers
|
|
6
|
+
from httpx import Headers as httpxHeaders
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class APIKey:
|
|
11
|
+
"""API key authentication configuration.
|
|
12
|
+
|
|
13
|
+
Controls how and when authentication is applied to requests.
|
|
14
|
+
|
|
15
|
+
**Scope** determines which endpoints require authentication:
|
|
16
|
+
- ``"All"``: Every request includes the API key automatically.
|
|
17
|
+
- ``"Endpoint"``: Only endpoints with ``requireAuth=True`` on their
|
|
18
|
+
parent resource include the API key.
|
|
19
|
+
- ``"Disabled"``: No authentication is applied.
|
|
20
|
+
|
|
21
|
+
**Scheme** determines where the API key is sent:
|
|
22
|
+
- ``"Header"``: Included as an HTTP header on every matching request.
|
|
23
|
+
- ``"Query"``: Appended as a query parameter on matching requests.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
key: The header or query parameter name (e.g. ``"x-api-key"``).
|
|
27
|
+
secret: The API key value.
|
|
28
|
+
scope: Authentication scope (``"All"``, ``"Endpoint"``, or ``"Disabled"``).
|
|
29
|
+
scheme: Delivery method (``"Header"`` or ``"Query"``).
|
|
30
|
+
|
|
31
|
+
Usage::
|
|
32
|
+
|
|
33
|
+
apiKey = APIKey(key="x-api-key", secret="sk-...", scope="All", scheme="Header")
|
|
34
|
+
client = Client(url="https://api.example.com", apiKey=apiKey)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
key: str
|
|
38
|
+
secret: str
|
|
39
|
+
scope: Literal["All", "Endpoint", "Disabled"]
|
|
40
|
+
scheme: Literal["Header", "Query"]
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def authenticate(self) -> dict[str, str]:
|
|
44
|
+
return {self.key: self.secret}
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def header(self) -> Headers:
|
|
48
|
+
return {self.key: self.secret}
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def query(self) -> Query:
|
|
52
|
+
return Query({self.key: self.secret})
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def util(self) -> APIKeyUtil:
|
|
56
|
+
return APIKeyUtil(self)
|
|
57
|
+
|
|
58
|
+
class APIKeyUtil:
|
|
59
|
+
"""Internal helper that injects API key credentials into headers or query params.
|
|
60
|
+
|
|
61
|
+
Used by the Client during request building to apply authentication based
|
|
62
|
+
on the APIKey's scope and scheme configuration.
|
|
63
|
+
"""
|
|
64
|
+
_apiKey: APIKey
|
|
65
|
+
def __init__(self, apiKey: APIKey):
|
|
66
|
+
self._apiKey = apiKey
|
|
67
|
+
|
|
68
|
+
def allInHeader(self, header: httpxHeaders):
|
|
69
|
+
"""Inject API key into headers if scope is ``"All"``."""
|
|
70
|
+
if self._apiKey.scope == "All":
|
|
71
|
+
header.update(self._apiKey.header)
|
|
72
|
+
return header
|
|
73
|
+
|
|
74
|
+
def allInQuery(self, query: Query):
|
|
75
|
+
"""Inject API key into query params if scope is ``"All"``."""
|
|
76
|
+
if self._apiKey.scope == "All":
|
|
77
|
+
return query.addPair(self._apiKey.key, self._apiKey.secret)
|
|
78
|
+
return query
|
|
79
|
+
|
|
80
|
+
def endpointInHeader(self, header: httpxHeaders, auth: bool):
|
|
81
|
+
"""Inject API key into headers if scope is ``"Endpoint"`` and auth is required."""
|
|
82
|
+
if self._apiKey.scope == "Endpoint" and auth:
|
|
83
|
+
return header.update(self._apiKey.header)
|
|
84
|
+
return header
|
|
85
|
+
|
|
86
|
+
def endpointInQuery(self, query: Query, auth: bool):
|
|
87
|
+
"""Inject API key into query params if scope is ``"Endpoint"`` and auth is required."""
|
|
88
|
+
if self._apiKey.scope == "Endpoint" and auth:
|
|
89
|
+
return query.addPair(self._apiKey.key, self._apiKey.secret)
|
|
90
|
+
return query
|
xapi/client.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Type, Optional, get_origin
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from pydantic import TypeAdapter
|
|
7
|
+
from .apiKey import APIKey
|
|
8
|
+
from .exceptions import (APIConnectionError, APIResponseValidationError,
|
|
9
|
+
APIStatusError, APITimeoutError)
|
|
10
|
+
from .resource import Resource
|
|
11
|
+
from .model import ResponseModels
|
|
12
|
+
from .endpoint import PP
|
|
13
|
+
from .retry import RetryConfig
|
|
14
|
+
from .query import Query
|
|
15
|
+
from .type import DEFAULT_TIMEOUT, JSON, HTTPMethod
|
|
16
|
+
from .url import URL
|
|
17
|
+
from .util import DataManager, Headersx
|
|
18
|
+
from .rateLimit import RateLimiter
|
|
19
|
+
from .logger import logger
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Client:
|
|
23
|
+
"""Async, resource-oriented API client for interacting with RESTful APIs.
|
|
24
|
+
|
|
25
|
+
Organizes endpoints into a tree of Resources, accessed via dot notation
|
|
26
|
+
(e.g. ``client.coins.coin(...)``). Handles authentication, rate limiting,
|
|
27
|
+
retries, and response parsing automatically.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
url: Base URL of the API (e.g. ``"https://api.example.com/v1/"``).
|
|
31
|
+
apiKey: Optional API key configuration for authentication.
|
|
32
|
+
timeout: Request timeout. Defaults to 30s overall, 5s connect.
|
|
33
|
+
rateLimit: Optional sliding-window rate limiter.
|
|
34
|
+
retry: Retry configuration with exponential backoff. Defaults to 3 attempts.
|
|
35
|
+
headers: Additional headers merged into every request.
|
|
36
|
+
debug: Enable debug logging for requests, bindings, and responses.
|
|
37
|
+
|
|
38
|
+
Usage::
|
|
39
|
+
|
|
40
|
+
async with Client(url="https://api.example.com/v1/") as client:
|
|
41
|
+
client.add(coinsResource)
|
|
42
|
+
coin = await client.coins.coin(parameters=CoinParams.Bitcoin)
|
|
43
|
+
"""
|
|
44
|
+
_logger = logger.debug
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def __init__(self,
|
|
48
|
+
url: str,
|
|
49
|
+
apiKey: APIKey | None = None,
|
|
50
|
+
timeout: httpx.Timeout = DEFAULT_TIMEOUT,
|
|
51
|
+
rateLimit: RateLimiter | None = None,
|
|
52
|
+
retry: RetryConfig | None = None,
|
|
53
|
+
headers: httpx.Headers | None = None,
|
|
54
|
+
debug: bool = False):
|
|
55
|
+
self.debug: bool = debug
|
|
56
|
+
logger.configure() if self.debug else None
|
|
57
|
+
self.url = URL.normalizeBaseURL(url)
|
|
58
|
+
self.apiKey = apiKey
|
|
59
|
+
self.retry = retry or RetryConfig()
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self.rateLimit = rateLimit
|
|
62
|
+
self.headers: httpx.Headers = httpx.Headers({**Headersx.default()})
|
|
63
|
+
self.headers.update(headers) if headers else {}
|
|
64
|
+
|
|
65
|
+
self.apiKey.util.allInHeader(self.headers) if self.apiKey else None
|
|
66
|
+
|
|
67
|
+
self.client = httpx.AsyncClient(
|
|
68
|
+
base_url=self.url,
|
|
69
|
+
timeout=self.timeout,
|
|
70
|
+
headers=self.headers
|
|
71
|
+
# event_hooks=HttpEventHooks.EventHooks()
|
|
72
|
+
)
|
|
73
|
+
self.unsetNestedData: bool = True
|
|
74
|
+
self._resources: Dict[str, Resource] = {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def add(self, resource: Resource | list[Resource]) -> None:
|
|
78
|
+
"""Register one or more resources with this client.
|
|
79
|
+
|
|
80
|
+
Each resource is bound to the client and becomes accessible as an
|
|
81
|
+
attribute (e.g. ``client.coins``). Raises ``ValueError`` if a
|
|
82
|
+
resource with the same name is already registered.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
resource: A single Resource or a list of Resources to register.
|
|
86
|
+
"""
|
|
87
|
+
_resources = resource if isinstance(resource, list) else [resource]
|
|
88
|
+
|
|
89
|
+
for r in _resources:
|
|
90
|
+
if r.name in self._resources:
|
|
91
|
+
raise ValueError(f"Resource {r.name} already exists")
|
|
92
|
+
r._bind(self)
|
|
93
|
+
self._resources[r.name] = r
|
|
94
|
+
setattr(self, r.name, r)
|
|
95
|
+
self._logger(f"[Client +] Resource->{r.name}") if self.debug else None
|
|
96
|
+
|
|
97
|
+
def __getattr__(self, item: str) -> Any:
|
|
98
|
+
resources = self.__dict__.get("_resources")
|
|
99
|
+
if resources is None:
|
|
100
|
+
raise AttributeError(item)
|
|
101
|
+
if item in resources:
|
|
102
|
+
return resources[item]
|
|
103
|
+
raise AttributeError(item)
|
|
104
|
+
|
|
105
|
+
async def _sendRequest(self,
|
|
106
|
+
url: str,
|
|
107
|
+
path: str,
|
|
108
|
+
pathPrefix: str,
|
|
109
|
+
response: Optional[Type[ResponseModels]] = None,
|
|
110
|
+
query: Query | None = None,
|
|
111
|
+
method: HTTPMethod | str = 'GET',
|
|
112
|
+
auth: bool = False,
|
|
113
|
+
strictValidation: bool = False) -> ResponseModels | JSON:
|
|
114
|
+
"""Send an HTTP request and parse the response.
|
|
115
|
+
|
|
116
|
+
Handles the full request lifecycle: authentication injection, rate limiting,
|
|
117
|
+
request building, response parsing, and retry logic.
|
|
118
|
+
|
|
119
|
+
List vs dict responses are auto-detected from the ``response`` type using
|
|
120
|
+
``get_origin()``. If ``response`` is ``list[Model]``, the raw JSON array
|
|
121
|
+
is validated directly. Otherwise, nested ``data`` keys are unwrapped and
|
|
122
|
+
API metadata is injected into the parsed ResponseModel.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
url: The full request path (prefix + endpoint path).
|
|
126
|
+
path: The endpoint-specific path segment.
|
|
127
|
+
pathPrefix: The resource prefix path.
|
|
128
|
+
response: The Pydantic model type for response parsing. Pass
|
|
129
|
+
``list[Model]`` for array responses, or a single ``ResponseModel``
|
|
130
|
+
subclass for object responses. ``None`` returns raw JSON.
|
|
131
|
+
query: Optional query parameters.
|
|
132
|
+
method: HTTP method (default ``GET``).
|
|
133
|
+
auth: Whether this request requires authentication.
|
|
134
|
+
strictValidation: Enable strict Pydantic validation mode.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Parsed model instance, list of models, or raw JSON dict.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
APIStatusError: On 4xx/5xx responses (subclassed by status code).
|
|
141
|
+
APITimeoutError: When all retry attempts time out.
|
|
142
|
+
APIConnectionError: When all retry attempts fail to connect.
|
|
143
|
+
APIResponseValidationError: When response data doesn't match the model.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
url = URL.normalizePath(url)
|
|
147
|
+
reqHeaders = self.headers
|
|
148
|
+
rateLimit = self.rateLimit
|
|
149
|
+
queries = query if query else Query({})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if (self.apiKey and self.apiKey.scope == "All") and auth:
|
|
153
|
+
self.apiKey.util.allInQuery(queries) if self.apiKey.scheme == "Query" else None
|
|
154
|
+
|
|
155
|
+
if self.apiKey and self.apiKey.scope == "Endpoint":
|
|
156
|
+
self.apiKey.util.endpointInQuery(queries, auth) if self.apiKey.scheme == "Query" else None
|
|
157
|
+
|
|
158
|
+
queries = queries.toQueryParams()
|
|
159
|
+
request = self.client.build_request(method, url, params=queries, headers=reqHeaders)
|
|
160
|
+
|
|
161
|
+
for attempt in range(self.retry.attempts):
|
|
162
|
+
try:
|
|
163
|
+
if rateLimit:
|
|
164
|
+
await rateLimit.acquire()
|
|
165
|
+
request = self.client.build_request(
|
|
166
|
+
method,
|
|
167
|
+
url,
|
|
168
|
+
params=queries,
|
|
169
|
+
headers=reqHeaders,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
resp = await self.client.send(request)
|
|
173
|
+
|
|
174
|
+
apiData = DataManager.buildAPIReponseData(
|
|
175
|
+
elapsed=resp.elapsed.total_seconds(),
|
|
176
|
+
method=method,
|
|
177
|
+
url=self.url + pathPrefix + path,
|
|
178
|
+
path=path,
|
|
179
|
+
pathPrefix=pathPrefix,
|
|
180
|
+
authScope=self.apiKey.scope if self.apiKey else "Disabled",
|
|
181
|
+
query=query.activeQueries if query else None)
|
|
182
|
+
|
|
183
|
+
logger.http(f"{method} /{pathPrefix + path} {resp.elapsed.total_seconds():.2f}s") if self.debug else None
|
|
184
|
+
|
|
185
|
+
if resp.status_code >= 400:
|
|
186
|
+
body = None
|
|
187
|
+
try:
|
|
188
|
+
body = resp.json()
|
|
189
|
+
except Exception:
|
|
190
|
+
body = resp.text
|
|
191
|
+
|
|
192
|
+
message = f"HTTP {resp.status_code}: {resp.reason_phrase} {resp.url} {resp.headers.get_list}"
|
|
193
|
+
raise APIStatusError.withStatus(message, response=resp, body=body)
|
|
194
|
+
logger.http(f"{resp}") if self.debug else None
|
|
195
|
+
rawData = resp.json()
|
|
196
|
+
|
|
197
|
+
responseIsList = get_origin(response) is list
|
|
198
|
+
|
|
199
|
+
if responseIsList:
|
|
200
|
+
parsedData = rawData
|
|
201
|
+
else:
|
|
202
|
+
parsedData: dict[
|
|
203
|
+
str,
|
|
204
|
+
Any] = rawData if not self.unsetNestedData else DataManager.unsetNestData(
|
|
205
|
+
rawData)
|
|
206
|
+
|
|
207
|
+
if response is None:
|
|
208
|
+
return parsedData
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
if responseIsList:
|
|
212
|
+
adapter = TypeAdapter(response)
|
|
213
|
+
parsed = adapter.validate_python(parsedData, strict=strictValidation)
|
|
214
|
+
else:
|
|
215
|
+
parsedData.update({"api": apiData})
|
|
216
|
+
adapter = TypeAdapter(response)
|
|
217
|
+
parsed = adapter.validate_python(parsedData, strict=strictValidation)
|
|
218
|
+
except Exception as err:
|
|
219
|
+
raise APIResponseValidationError(
|
|
220
|
+
response=resp,
|
|
221
|
+
body=parsedData,
|
|
222
|
+
message=f"Failed to parse response: {err}") from err
|
|
223
|
+
return parsed
|
|
224
|
+
|
|
225
|
+
except httpx.TimeoutException as err:
|
|
226
|
+
if attempt == self.retry.attempts - 1:
|
|
227
|
+
raise APITimeoutError(request=request) from err
|
|
228
|
+
await self.retry.sleep(attempt)
|
|
229
|
+
|
|
230
|
+
except APIStatusError as err:
|
|
231
|
+
if err.status_code < 500:
|
|
232
|
+
raise # 4xx errors — don't retry
|
|
233
|
+
if attempt == self.retry.attempts - 1:
|
|
234
|
+
raise
|
|
235
|
+
await self.retry.sleep(attempt)
|
|
236
|
+
|
|
237
|
+
except httpx.ConnectError as err:
|
|
238
|
+
if attempt == self.retry.attempts - 1:
|
|
239
|
+
raise APIConnectionError(request=request) from err
|
|
240
|
+
|
|
241
|
+
await self.retry.sleep(attempt)
|
|
242
|
+
|
|
243
|
+
except Exception:
|
|
244
|
+
if attempt == self.retry.attempts - 1:
|
|
245
|
+
raise
|
|
246
|
+
await self.retry.sleep(attempt)
|
|
247
|
+
|
|
248
|
+
raise RuntimeError("Request failed")
|
|
249
|
+
|
|
250
|
+
async def _endpointRequest(self,
|
|
251
|
+
id: str,
|
|
252
|
+
path: str,
|
|
253
|
+
prefix: str,
|
|
254
|
+
method: HTTPMethod | str,
|
|
255
|
+
auth: bool,
|
|
256
|
+
parameters: Optional[PP | list[PP]] = None,
|
|
257
|
+
query: Query | None = None,
|
|
258
|
+
model: Optional[Type[ResponseModels]] = None,
|
|
259
|
+
strictValidation: bool = False):
|
|
260
|
+
"""Internal bridge between Resource.prepareRequest and _sendRequest.
|
|
261
|
+
|
|
262
|
+
Assembles the full URL from prefix + path and delegates to _sendRequest.
|
|
263
|
+
"""
|
|
264
|
+
url = prefix + path
|
|
265
|
+
return await self._sendRequest(url=url,
|
|
266
|
+
path=path,
|
|
267
|
+
pathPrefix=prefix,
|
|
268
|
+
response=model,
|
|
269
|
+
query=query,
|
|
270
|
+
method=method,
|
|
271
|
+
auth=auth,
|
|
272
|
+
strictValidation=strictValidation)
|
|
273
|
+
|
|
274
|
+
async def __aenter__(self) -> Client:
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
278
|
+
await self.client.aclose()
|
|
279
|
+
|
|
280
|
+
async def close(self) -> None:
|
|
281
|
+
"""Close the underlying HTTP connection pool.
|
|
282
|
+
|
|
283
|
+
Called automatically when using the client as an async context manager.
|
|
284
|
+
"""
|
|
285
|
+
self._logger("[Client] Closing client") if self.debug else None
|
|
286
|
+
await self.client.aclose()
|
xapi/endpoint.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import (Protocol, TypeVar, Optional, Type, TYPE_CHECKING)
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from .path import Path, Parameters
|
|
5
|
+
from .type import HTTPMethod
|
|
6
|
+
from .logger import logger
|
|
7
|
+
from .model import ResponseModel, ResponseModels
|
|
8
|
+
from .exceptions import BindingError
|
|
9
|
+
import shortuuid
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .client import Client
|
|
12
|
+
from .resource import Resource
|
|
13
|
+
|
|
14
|
+
logger.configure()
|
|
15
|
+
|
|
16
|
+
R = TypeVar("R", bound="ResponseModel", covariant=True)
|
|
17
|
+
PP = TypeVar("PP", bound="Parameters", contravariant=True)
|
|
18
|
+
|
|
19
|
+
class EndpointCall(Protocol[R, PP]):
|
|
20
|
+
"""Protocol for callable endpoint objects."""
|
|
21
|
+
|
|
22
|
+
async def call(self, parameters: Optional[PP | list[PP]] = None, **kwargs):
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Endpoint[R, PP]:
|
|
27
|
+
"""A single API endpoint definition.
|
|
28
|
+
|
|
29
|
+
Represents one callable API operation (e.g. ``GET /coins/{id}``). Endpoints
|
|
30
|
+
are attached to a Resource and become callable via dot notation on the client.
|
|
31
|
+
|
|
32
|
+
The ``response`` type determines how the API response is parsed:
|
|
33
|
+
- ``ResponseModel`` subclass: parsed as a single object, with API metadata
|
|
34
|
+
injected into the ``api`` field.
|
|
35
|
+
- ``list[BaseModel]``: parsed as a JSON array of models (auto-detected).
|
|
36
|
+
- ``None``: returns raw JSON dict without parsing.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
name: Identifier used as the Python attribute name on the resource.
|
|
40
|
+
path: Path object defining the URL template and parameter requirements.
|
|
41
|
+
nameOverride: Optional display name for the endpoint (defaults to ``name``).
|
|
42
|
+
response: Pydantic model type for response parsing. Use ``list[Model]`` for
|
|
43
|
+
array responses.
|
|
44
|
+
method: HTTP method. Defaults to ``GET``.
|
|
45
|
+
strict: Enable strict Pydantic validation mode.
|
|
46
|
+
debug: Enable debug logging for this endpoint.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
__id__: str = field(init=False)
|
|
50
|
+
__client__: Optional[Client] = field(init=False, default=None)
|
|
51
|
+
__boundResource__: Optional[Resource] = field(init=False, default=None)
|
|
52
|
+
__prefix__: str = field(init=False, default="")
|
|
53
|
+
__apiName__: str = field(init=False, default="")
|
|
54
|
+
_requireAuth: bool = field(init=False, default=False)
|
|
55
|
+
name: str
|
|
56
|
+
path: Path[PP]
|
|
57
|
+
nameOverride: str = ""
|
|
58
|
+
response: Optional[Type[ResponseModels]] = None
|
|
59
|
+
method: HTTPMethod = "GET"
|
|
60
|
+
strict: bool = False
|
|
61
|
+
|
|
62
|
+
debug: bool = False
|
|
63
|
+
|
|
64
|
+
def __post_init__(self):
|
|
65
|
+
self.__id__ = str(shortuuid.uuid(name=self.name+self.path.endpointPath))
|
|
66
|
+
self.__apiName__ = self.nameOverride if self.nameOverride != "" else self.name
|
|
67
|
+
|
|
68
|
+
def _bind(self, resource: Resource, prefix: str = "") -> None:
|
|
69
|
+
self.__boundResource__ = resource
|
|
70
|
+
self.debug = self.__boundResource__.debug
|
|
71
|
+
self._requireAuth = self.__boundResource__._requireAuth
|
|
72
|
+
logger.debug(f"[Bind] {resource.name}<-{self.name}") if self.debug else None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def id(self) -> str:
|
|
76
|
+
return self.__id__
|
|
77
|
+
|
|
78
|
+
def _getPath(self, params: Optional[PP | list[PP]] = None) -> str:
|
|
79
|
+
if params is not None and self.path.requiresParameters:
|
|
80
|
+
return self.path.path(params=params)
|
|
81
|
+
return self.path.path()
|
|
82
|
+
|
|
83
|
+
async def call(self, parameters: Optional[PP | list[PP]] = None, **kwargs):
|
|
84
|
+
"""Execute this endpoint's API call.
|
|
85
|
+
|
|
86
|
+
Resolves the URL path with any provided parameters, then delegates
|
|
87
|
+
to the bound resource to send the request through the client.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
parameters: Path parameter values to inject into the URL template.
|
|
91
|
+
**kwargs: Additional options. Pass ``query=Query(...)`` to include
|
|
92
|
+
query parameters.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Parsed response model, list of models, or raw JSON dict.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
BindingError: If this endpoint hasn't been added to a resource.
|
|
99
|
+
"""
|
|
100
|
+
if not self.__boundResource__:
|
|
101
|
+
raise BindingError(self.name, "Resource")
|
|
102
|
+
|
|
103
|
+
if self.path.requiresParameters and parameters:
|
|
104
|
+
path = self._getPath(parameters)
|
|
105
|
+
else:
|
|
106
|
+
path = self._getPath()
|
|
107
|
+
|
|
108
|
+
if not self.__boundResource__._pathProtection:
|
|
109
|
+
pFix = self.__boundResource__._prefix
|
|
110
|
+
else:
|
|
111
|
+
pFix = self.__boundResource__._prefix
|
|
112
|
+
|
|
113
|
+
queries = kwargs.get("query")
|
|
114
|
+
|
|
115
|
+
result = await self.__boundResource__.prepareRequest(
|
|
116
|
+
id=self.__id__,
|
|
117
|
+
path=path,
|
|
118
|
+
prefix=pFix,
|
|
119
|
+
method=self.method,
|
|
120
|
+
auth=self._requireAuth,
|
|
121
|
+
model=self.response,
|
|
122
|
+
strictValidation=self.strict,
|
|
123
|
+
query=queries,
|
|
124
|
+
)
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
type Endpoints[R, PP] = Endpoint[R, PP]
|