reflectapi-runtime 0.1.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.
- reflectapi_runtime/__init__.py +109 -0
- reflectapi_runtime/auth.py +507 -0
- reflectapi_runtime/batch.py +165 -0
- reflectapi_runtime/client.py +924 -0
- reflectapi_runtime/exceptions.py +120 -0
- reflectapi_runtime/hypothesis_strategies.py +275 -0
- reflectapi_runtime/middleware.py +254 -0
- reflectapi_runtime/option.py +295 -0
- reflectapi_runtime/response.py +126 -0
- reflectapi_runtime/streaming.py +435 -0
- reflectapi_runtime/testing.py +380 -0
- reflectapi_runtime/types.py +32 -0
- reflectapi_runtime-0.1.0.dist-info/METADATA +36 -0
- reflectapi_runtime-0.1.0.dist-info/RECORD +15 -0
- reflectapi_runtime-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
"""Base client classes for ReflectAPI Python clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from abc import ABC
|
|
9
|
+
from typing import Any, TypeVar, overload
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
14
|
+
|
|
15
|
+
from .auth import AuthHandler
|
|
16
|
+
from .exceptions import ApplicationError, NetworkError, TimeoutError, ValidationError
|
|
17
|
+
from .middleware import (
|
|
18
|
+
AsyncMiddleware,
|
|
19
|
+
AsyncMiddlewareChain,
|
|
20
|
+
SyncMiddleware,
|
|
21
|
+
SyncMiddlewareChain,
|
|
22
|
+
)
|
|
23
|
+
from .option import serialize_option_dict
|
|
24
|
+
from .response import ApiResponse, TransportMetadata
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Sentinel object to represent "no validation needed"
|
|
28
|
+
class _NoValidation:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
NO_VALIDATION = _NoValidation()
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T", bound=BaseModel)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _json_serializer(obj: Any) -> Any:
|
|
38
|
+
"""JSON serializer function for datetime and Pydantic objects."""
|
|
39
|
+
if isinstance(obj, datetime.datetime):
|
|
40
|
+
return obj.isoformat()
|
|
41
|
+
elif isinstance(obj, datetime.date):
|
|
42
|
+
return obj.isoformat()
|
|
43
|
+
elif hasattr(obj, 'model_dump'):
|
|
44
|
+
return obj.model_dump(exclude_none=True)
|
|
45
|
+
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Note: AsyncAuthWrapper removed - AuthHandler now inherits from httpx.Auth directly
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ClientBase(ABC):
|
|
52
|
+
"""Base class for synchronous ReflectAPI clients."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
base_url: str,
|
|
57
|
+
*,
|
|
58
|
+
timeout: float | None = 30.0,
|
|
59
|
+
headers: dict[str, str] | None = None,
|
|
60
|
+
middleware: list[SyncMiddleware] | None = None,
|
|
61
|
+
auth: AuthHandler | httpx.Auth | None = None,
|
|
62
|
+
client: httpx.Client | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.base_url = base_url.rstrip("/")
|
|
65
|
+
self.middleware_chain = SyncMiddlewareChain(middleware or [])
|
|
66
|
+
self.auth = auth
|
|
67
|
+
|
|
68
|
+
# Use provided client or create a new one
|
|
69
|
+
if client is not None:
|
|
70
|
+
self._client = client
|
|
71
|
+
self._owns_client = False
|
|
72
|
+
else:
|
|
73
|
+
# Handle authentication
|
|
74
|
+
auth_param = None
|
|
75
|
+
if isinstance(auth, AuthHandler):
|
|
76
|
+
# Use our custom auth handler as httpx auth
|
|
77
|
+
auth_param = auth
|
|
78
|
+
elif auth is not None:
|
|
79
|
+
# Use httpx built-in auth directly
|
|
80
|
+
auth_param = auth
|
|
81
|
+
|
|
82
|
+
self._client = httpx.Client(
|
|
83
|
+
base_url=self.base_url,
|
|
84
|
+
timeout=timeout,
|
|
85
|
+
headers=headers or {},
|
|
86
|
+
auth=auth_param,
|
|
87
|
+
)
|
|
88
|
+
self._owns_client = True
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> ClientBase:
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
94
|
+
self.close()
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
"""Close the underlying HTTP client if we own it."""
|
|
98
|
+
if self._owns_client:
|
|
99
|
+
self._client.close()
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_bearer_token(
|
|
103
|
+
cls,
|
|
104
|
+
base_url: str,
|
|
105
|
+
token: str,
|
|
106
|
+
**kwargs: Any,
|
|
107
|
+
) -> ClientBase:
|
|
108
|
+
"""Create a client with Bearer token authentication."""
|
|
109
|
+
from .auth import BearerTokenAuth
|
|
110
|
+
return cls(base_url, auth=BearerTokenAuth(token), **kwargs)
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_api_key(
|
|
114
|
+
cls,
|
|
115
|
+
base_url: str,
|
|
116
|
+
api_key: str,
|
|
117
|
+
header_name: str = "X-API-Key",
|
|
118
|
+
param_name: str | None = None,
|
|
119
|
+
**kwargs: Any,
|
|
120
|
+
) -> ClientBase:
|
|
121
|
+
"""Create a client with API key authentication."""
|
|
122
|
+
from .auth import APIKeyAuth
|
|
123
|
+
return cls(base_url, auth=APIKeyAuth(api_key, header_name, param_name), **kwargs)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_basic_auth(
|
|
127
|
+
cls,
|
|
128
|
+
base_url: str,
|
|
129
|
+
username: str,
|
|
130
|
+
password: str,
|
|
131
|
+
**kwargs: Any,
|
|
132
|
+
) -> ClientBase:
|
|
133
|
+
"""Create a client with HTTP Basic authentication."""
|
|
134
|
+
from .auth import BasicAuth
|
|
135
|
+
return cls(base_url, auth=BasicAuth(username, password), **kwargs)
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_oauth2_client_credentials(
|
|
139
|
+
cls,
|
|
140
|
+
base_url: str,
|
|
141
|
+
token_url: str,
|
|
142
|
+
client_id: str,
|
|
143
|
+
client_secret: str,
|
|
144
|
+
scope: str | None = None,
|
|
145
|
+
**kwargs: Any,
|
|
146
|
+
) -> ClientBase:
|
|
147
|
+
"""Create a client with OAuth2 client credentials authentication."""
|
|
148
|
+
from .auth import OAuth2ClientCredentialsAuth
|
|
149
|
+
return cls(
|
|
150
|
+
base_url,
|
|
151
|
+
auth=OAuth2ClientCredentialsAuth(token_url, client_id, client_secret, scope),
|
|
152
|
+
**kwargs
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@overload
|
|
156
|
+
def _make_request(
|
|
157
|
+
self,
|
|
158
|
+
method: str,
|
|
159
|
+
path: str,
|
|
160
|
+
*,
|
|
161
|
+
params: dict[str, Any] | None = None,
|
|
162
|
+
json_data: dict[str, Any] | None = None,
|
|
163
|
+
json_model: None = None,
|
|
164
|
+
headers_model: BaseModel | None = None,
|
|
165
|
+
response_model: type[T],
|
|
166
|
+
) -> ApiResponse[T]: ...
|
|
167
|
+
|
|
168
|
+
@overload
|
|
169
|
+
def _make_request(
|
|
170
|
+
self,
|
|
171
|
+
method: str,
|
|
172
|
+
path: str,
|
|
173
|
+
*,
|
|
174
|
+
params: dict[str, Any] | None = None,
|
|
175
|
+
json_data: dict[str, Any] | None = None,
|
|
176
|
+
json_model: None = None,
|
|
177
|
+
headers_model: BaseModel | None = None,
|
|
178
|
+
response_model: None = None,
|
|
179
|
+
) -> ApiResponse[dict[str, Any]]: ...
|
|
180
|
+
|
|
181
|
+
@overload
|
|
182
|
+
def _make_request(
|
|
183
|
+
self,
|
|
184
|
+
method: str,
|
|
185
|
+
path: str,
|
|
186
|
+
*,
|
|
187
|
+
params: dict[str, Any] | None = None,
|
|
188
|
+
json_data: dict[str, Any] | None = None,
|
|
189
|
+
json_model: None = None,
|
|
190
|
+
headers_model: BaseModel | None = None,
|
|
191
|
+
response_model: type[Any],
|
|
192
|
+
) -> ApiResponse[dict[str, Any]]: ...
|
|
193
|
+
|
|
194
|
+
@overload
|
|
195
|
+
def _make_request(
|
|
196
|
+
self,
|
|
197
|
+
method: str,
|
|
198
|
+
path: str,
|
|
199
|
+
*,
|
|
200
|
+
params: dict[str, Any] | None = None,
|
|
201
|
+
json_data: dict[str, Any] | None = None,
|
|
202
|
+
json_model: None = None,
|
|
203
|
+
response_model: str,
|
|
204
|
+
) -> ApiResponse[dict[str, Any]]: ...
|
|
205
|
+
|
|
206
|
+
@overload
|
|
207
|
+
def _make_request(
|
|
208
|
+
self,
|
|
209
|
+
method: str,
|
|
210
|
+
path: str,
|
|
211
|
+
*,
|
|
212
|
+
params: dict[str, Any] | None = None,
|
|
213
|
+
json_data: None = None,
|
|
214
|
+
json_model: BaseModel,
|
|
215
|
+
headers_model: BaseModel | None = None,
|
|
216
|
+
response_model: type[T],
|
|
217
|
+
) -> ApiResponse[T]: ...
|
|
218
|
+
|
|
219
|
+
@overload
|
|
220
|
+
def _make_request(
|
|
221
|
+
self,
|
|
222
|
+
method: str,
|
|
223
|
+
path: str,
|
|
224
|
+
*,
|
|
225
|
+
params: dict[str, Any] | None = None,
|
|
226
|
+
json_data: None = None,
|
|
227
|
+
json_model: BaseModel,
|
|
228
|
+
headers_model: BaseModel | None = None,
|
|
229
|
+
response_model: None = None,
|
|
230
|
+
) -> ApiResponse[dict[str, Any]]: ...
|
|
231
|
+
|
|
232
|
+
def _validate_request_params(
|
|
233
|
+
self,
|
|
234
|
+
json_data: dict[str, Any] | None,
|
|
235
|
+
json_model: BaseModel | None,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Validate request parameters for conflicts."""
|
|
238
|
+
if json_model is not None and json_data is not None:
|
|
239
|
+
raise ValueError("Cannot specify both json_data and json_model")
|
|
240
|
+
|
|
241
|
+
def _serialize_request_body(self, json_model: BaseModel | int | float | str | bool | list | dict) -> tuple[bytes, dict[str, str]]:
|
|
242
|
+
"""Serialize request body from Pydantic model or primitive type."""
|
|
243
|
+
from .option import ReflectapiOption
|
|
244
|
+
|
|
245
|
+
# Handle primitive types (for untagged unions)
|
|
246
|
+
if not hasattr(json_model, 'model_dump'):
|
|
247
|
+
content = json.dumps(json_model, default=_json_serializer, separators=(',', ':')).encode('utf-8')
|
|
248
|
+
headers = {"Content-Type": "application/json"}
|
|
249
|
+
return content, headers
|
|
250
|
+
|
|
251
|
+
# Check if model has any ReflectapiOption fields that need special handling
|
|
252
|
+
raw_data = json_model.model_dump(exclude_none=False)
|
|
253
|
+
|
|
254
|
+
# Handle case where RootModel serializes to primitive value (e.g., strings for unit variants)
|
|
255
|
+
if not isinstance(raw_data, dict):
|
|
256
|
+
# For primitive values, use Pydantic's built-in JSON serialization
|
|
257
|
+
content = json_model.model_dump_json(exclude_none=True, by_alias=True).encode('utf-8')
|
|
258
|
+
headers = {"Content-Type": "application/json"}
|
|
259
|
+
return content, headers
|
|
260
|
+
|
|
261
|
+
has_reflectapi_options = any(
|
|
262
|
+
isinstance(field_value, ReflectapiOption)
|
|
263
|
+
for field_value in raw_data.values()
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if has_reflectapi_options:
|
|
267
|
+
# Process each field to handle ReflectapiOption properly
|
|
268
|
+
processed_fields = {}
|
|
269
|
+
for field_name, field_value in raw_data.items():
|
|
270
|
+
if isinstance(field_value, ReflectapiOption):
|
|
271
|
+
if not field_value.is_undefined:
|
|
272
|
+
# Include the unwrapped value (including None for explicit null)
|
|
273
|
+
processed_fields[field_name] = field_value._value
|
|
274
|
+
# Skip undefined fields entirely - don't include them at all
|
|
275
|
+
else:
|
|
276
|
+
# Include all other fields that aren't None (unless they're meaningful None values)
|
|
277
|
+
if field_value is not None:
|
|
278
|
+
processed_fields[field_name] = field_value
|
|
279
|
+
|
|
280
|
+
# Use json serialization with datetime handler for proper serialization
|
|
281
|
+
content = json.dumps(processed_fields, default=_json_serializer, separators=(',', ':')).encode('utf-8')
|
|
282
|
+
else:
|
|
283
|
+
# Use Pydantic's built-in JSON serialization with exclude_none and by_alias for proper handling
|
|
284
|
+
content = json_model.model_dump_json(exclude_none=True, by_alias=True).encode('utf-8')
|
|
285
|
+
|
|
286
|
+
headers = {"Content-Type": "application/json"}
|
|
287
|
+
return content, headers
|
|
288
|
+
|
|
289
|
+
def _build_headers(self, base_headers: dict[str, str], headers_model: BaseModel | None) -> dict[str, str]:
|
|
290
|
+
"""Build complete headers dict including custom headers from headers_model."""
|
|
291
|
+
headers = base_headers.copy()
|
|
292
|
+
|
|
293
|
+
# Add headers from headers_model if provided
|
|
294
|
+
if headers_model is not None:
|
|
295
|
+
header_dict = headers_model.model_dump(by_alias=True, exclude_unset=True)
|
|
296
|
+
for key, value in header_dict.items():
|
|
297
|
+
if value is not None:
|
|
298
|
+
headers[key] = str(value)
|
|
299
|
+
|
|
300
|
+
return headers
|
|
301
|
+
|
|
302
|
+
def _build_request(
|
|
303
|
+
self,
|
|
304
|
+
method: str,
|
|
305
|
+
url: str,
|
|
306
|
+
params: dict[str, Any] | None,
|
|
307
|
+
json_data: dict[str, Any] | None,
|
|
308
|
+
json_model: BaseModel | None,
|
|
309
|
+
headers_model: BaseModel | None,
|
|
310
|
+
) -> httpx.Request:
|
|
311
|
+
"""Build HTTP request object."""
|
|
312
|
+
if json_model is not None:
|
|
313
|
+
# Serialize Pydantic model
|
|
314
|
+
content, base_headers = self._serialize_request_body(json_model)
|
|
315
|
+
headers = self._build_headers(base_headers, headers_model)
|
|
316
|
+
|
|
317
|
+
return self._client.build_request(
|
|
318
|
+
method=method,
|
|
319
|
+
url=url,
|
|
320
|
+
params=params,
|
|
321
|
+
content=content,
|
|
322
|
+
headers=headers,
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
# Handle JSON data with Option types
|
|
326
|
+
if json_data is not None:
|
|
327
|
+
# Only serialize Option types for dictionaries (complex types)
|
|
328
|
+
# Primitive types (int, str, bool, etc.) should be passed directly
|
|
329
|
+
if isinstance(json_data, dict):
|
|
330
|
+
processed_json_data = serialize_option_dict(json_data)
|
|
331
|
+
else:
|
|
332
|
+
# Primitive types - pass through directly
|
|
333
|
+
processed_json_data = json_data
|
|
334
|
+
else:
|
|
335
|
+
processed_json_data = json_data
|
|
336
|
+
|
|
337
|
+
# Build headers for requests without json_model
|
|
338
|
+
headers = self._build_headers({}, headers_model)
|
|
339
|
+
|
|
340
|
+
return self._client.build_request(
|
|
341
|
+
method=method,
|
|
342
|
+
url=url,
|
|
343
|
+
params=params,
|
|
344
|
+
json=processed_json_data,
|
|
345
|
+
headers=headers if headers else None,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def _execute_request(self, request: httpx.Request) -> httpx.Response:
|
|
349
|
+
"""Execute HTTP request through middleware chain."""
|
|
350
|
+
if self.middleware_chain.middleware:
|
|
351
|
+
return self.middleware_chain.execute(request, self._client)
|
|
352
|
+
else:
|
|
353
|
+
return self._client.send(request)
|
|
354
|
+
|
|
355
|
+
def _handle_error_response(self, response: httpx.Response, metadata: TransportMetadata) -> None:
|
|
356
|
+
"""Handle HTTP error responses (4xx, 5xx)."""
|
|
357
|
+
if response.status_code >= 400:
|
|
358
|
+
error_data = None
|
|
359
|
+
try:
|
|
360
|
+
error_data = response.json()
|
|
361
|
+
except Exception:
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
raise ApplicationError.from_response(response, metadata, error_data)
|
|
365
|
+
|
|
366
|
+
def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]:
|
|
367
|
+
"""Parse JSON response with error handling."""
|
|
368
|
+
try:
|
|
369
|
+
return response.json()
|
|
370
|
+
except Exception as e:
|
|
371
|
+
raise ValidationError(
|
|
372
|
+
f"Failed to parse JSON response: {e}",
|
|
373
|
+
cause=e,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def _validate_response_model(
|
|
377
|
+
self,
|
|
378
|
+
response: httpx.Response,
|
|
379
|
+
response_model: type[T] | type[Any] | str | _NoValidation,
|
|
380
|
+
metadata: TransportMetadata,
|
|
381
|
+
) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
|
|
382
|
+
"""Validate response using Pydantic model."""
|
|
383
|
+
# Handle special cases where no validation is needed
|
|
384
|
+
if response_model == "Any" or response_model is NO_VALIDATION:
|
|
385
|
+
json_response = self._parse_json_response(response)
|
|
386
|
+
return ApiResponse(json_response, metadata)
|
|
387
|
+
|
|
388
|
+
# Handle typing.Any
|
|
389
|
+
try:
|
|
390
|
+
if response_model is Any:
|
|
391
|
+
json_response = self._parse_json_response(response)
|
|
392
|
+
return ApiResponse(json_response, metadata)
|
|
393
|
+
except Exception:
|
|
394
|
+
# If there's any issue with the comparison, continue with validation
|
|
395
|
+
pass
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
# Handle Union types (like MyapiModelOutputPet | None)
|
|
399
|
+
import types
|
|
400
|
+
if hasattr(types, 'UnionType') and isinstance(response_model, types.UnionType):
|
|
401
|
+
json_response = self._parse_json_response(response)
|
|
402
|
+
# For Union types, try to deserialize with each type in the union
|
|
403
|
+
union_args = response_model.__args__
|
|
404
|
+
|
|
405
|
+
# Handle None case first
|
|
406
|
+
if json_response is None and type(None) in union_args:
|
|
407
|
+
return ApiResponse(None, metadata)
|
|
408
|
+
|
|
409
|
+
# Try each non-None type in the union
|
|
410
|
+
for arg_type in union_args:
|
|
411
|
+
if arg_type is not type(None) and hasattr(arg_type, "model_validate"):
|
|
412
|
+
try:
|
|
413
|
+
validated_data = arg_type.model_validate(json_response)
|
|
414
|
+
return ApiResponse(validated_data, metadata)
|
|
415
|
+
except Exception:
|
|
416
|
+
continue # Try next type
|
|
417
|
+
|
|
418
|
+
# If none of the types worked, return as dict
|
|
419
|
+
return ApiResponse(json_response, metadata)
|
|
420
|
+
|
|
421
|
+
# Type guard to ensure we have a model with validation methods
|
|
422
|
+
if not (isinstance(response_model, type) and hasattr(response_model, "model_validate")):
|
|
423
|
+
# Shouldn't happen, but fallback to JSON parsing
|
|
424
|
+
json_response = self._parse_json_response(response)
|
|
425
|
+
return ApiResponse(json_response, metadata)
|
|
426
|
+
|
|
427
|
+
# Use model_validate_json for high-performance parsing
|
|
428
|
+
if hasattr(response_model, "model_validate_json"):
|
|
429
|
+
validated_data = response_model.model_validate_json(response.content)
|
|
430
|
+
else:
|
|
431
|
+
# Fallback to old method for compatibility
|
|
432
|
+
json_response = self._parse_json_response(response)
|
|
433
|
+
validated_data = response_model.model_validate(json_response)
|
|
434
|
+
|
|
435
|
+
return ApiResponse(validated_data, metadata)
|
|
436
|
+
except PydanticValidationError as e:
|
|
437
|
+
raise ValidationError(
|
|
438
|
+
f"Response validation failed: {e}",
|
|
439
|
+
validation_errors=e.errors(),
|
|
440
|
+
cause=e,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def _make_request(
|
|
444
|
+
self,
|
|
445
|
+
method: str,
|
|
446
|
+
path: str,
|
|
447
|
+
*,
|
|
448
|
+
params: dict[str, Any] | None = None,
|
|
449
|
+
json_data: dict[str, Any] | None = None,
|
|
450
|
+
json_model: BaseModel | None = None,
|
|
451
|
+
headers_model: BaseModel | None = None,
|
|
452
|
+
response_model: type[T] | type[Any] | str | _NoValidation | None = None,
|
|
453
|
+
) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
|
|
454
|
+
"""Make an HTTP request and return an ApiResponse."""
|
|
455
|
+
# Validate request parameters
|
|
456
|
+
self._validate_request_params(json_data, json_model)
|
|
457
|
+
|
|
458
|
+
# Build URL and request
|
|
459
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
460
|
+
request = self._build_request(method, url, params, json_data, json_model, headers_model)
|
|
461
|
+
|
|
462
|
+
# Execute request with timing
|
|
463
|
+
start_time = time.time()
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
response = self._execute_request(request)
|
|
467
|
+
metadata = TransportMetadata.from_response(response, start_time)
|
|
468
|
+
|
|
469
|
+
# Handle error responses
|
|
470
|
+
self._handle_error_response(response, metadata)
|
|
471
|
+
|
|
472
|
+
# Validate and return response
|
|
473
|
+
if response_model is not None:
|
|
474
|
+
return self._validate_response_model(response, response_model, metadata)
|
|
475
|
+
else:
|
|
476
|
+
# No response_model provided - parse JSON into dict
|
|
477
|
+
json_response = self._parse_json_response(response)
|
|
478
|
+
return ApiResponse(json_response, metadata)
|
|
479
|
+
|
|
480
|
+
except httpx.TimeoutException as e:
|
|
481
|
+
raise TimeoutError.from_httpx_timeout(e)
|
|
482
|
+
except httpx.RequestError as e:
|
|
483
|
+
raise NetworkError.from_httpx_error(e)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class AsyncClientBase(ABC):
|
|
487
|
+
"""Base class for asynchronous ReflectAPI clients."""
|
|
488
|
+
|
|
489
|
+
def __init__(
|
|
490
|
+
self,
|
|
491
|
+
base_url: str,
|
|
492
|
+
*,
|
|
493
|
+
timeout: float | None = 30.0,
|
|
494
|
+
headers: dict[str, str] | None = None,
|
|
495
|
+
middleware: list[AsyncMiddleware] | None = None,
|
|
496
|
+
auth: AuthHandler | httpx.Auth | None = None,
|
|
497
|
+
client: httpx.AsyncClient | None = None,
|
|
498
|
+
) -> None:
|
|
499
|
+
self.base_url = base_url.rstrip("/")
|
|
500
|
+
self.middleware_chain = AsyncMiddlewareChain(middleware or [])
|
|
501
|
+
self.auth = auth
|
|
502
|
+
|
|
503
|
+
# Use provided client or create a new one
|
|
504
|
+
if client is not None:
|
|
505
|
+
self._client = client
|
|
506
|
+
self._owns_client = False
|
|
507
|
+
else:
|
|
508
|
+
# Handle authentication for async client
|
|
509
|
+
auth_param = None
|
|
510
|
+
if isinstance(auth, AuthHandler):
|
|
511
|
+
# Create wrapper for async auth handler
|
|
512
|
+
auth_param = auth # AuthHandler now inherits from httpx.Auth
|
|
513
|
+
elif auth is not None:
|
|
514
|
+
# Use httpx built-in auth directly
|
|
515
|
+
auth_param = auth
|
|
516
|
+
|
|
517
|
+
self._client = httpx.AsyncClient(
|
|
518
|
+
base_url=self.base_url,
|
|
519
|
+
timeout=timeout,
|
|
520
|
+
headers=headers or {},
|
|
521
|
+
auth=auth_param,
|
|
522
|
+
)
|
|
523
|
+
self._owns_client = True
|
|
524
|
+
|
|
525
|
+
async def __aenter__(self) -> AsyncClientBase:
|
|
526
|
+
return self
|
|
527
|
+
|
|
528
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
529
|
+
await self.aclose()
|
|
530
|
+
|
|
531
|
+
async def aclose(self) -> None:
|
|
532
|
+
"""Close the underlying HTTP client if we own it."""
|
|
533
|
+
if self._owns_client:
|
|
534
|
+
await self._client.aclose()
|
|
535
|
+
|
|
536
|
+
@classmethod
|
|
537
|
+
def from_bearer_token(
|
|
538
|
+
cls,
|
|
539
|
+
base_url: str,
|
|
540
|
+
token: str,
|
|
541
|
+
**kwargs: Any,
|
|
542
|
+
) -> AsyncClientBase:
|
|
543
|
+
"""Create a client with Bearer token authentication."""
|
|
544
|
+
from .auth import BearerTokenAuth
|
|
545
|
+
return cls(base_url, auth=BearerTokenAuth(token), **kwargs)
|
|
546
|
+
|
|
547
|
+
@classmethod
|
|
548
|
+
def from_api_key(
|
|
549
|
+
cls,
|
|
550
|
+
base_url: str,
|
|
551
|
+
api_key: str,
|
|
552
|
+
header_name: str = "X-API-Key",
|
|
553
|
+
param_name: str | None = None,
|
|
554
|
+
**kwargs: Any,
|
|
555
|
+
) -> AsyncClientBase:
|
|
556
|
+
"""Create a client with API key authentication."""
|
|
557
|
+
from .auth import APIKeyAuth
|
|
558
|
+
return cls(base_url, auth=APIKeyAuth(api_key, header_name, param_name), **kwargs)
|
|
559
|
+
|
|
560
|
+
@classmethod
|
|
561
|
+
def from_basic_auth(
|
|
562
|
+
cls,
|
|
563
|
+
base_url: str,
|
|
564
|
+
username: str,
|
|
565
|
+
password: str,
|
|
566
|
+
**kwargs: Any,
|
|
567
|
+
) -> AsyncClientBase:
|
|
568
|
+
"""Create a client with HTTP Basic authentication."""
|
|
569
|
+
from .auth import BasicAuth
|
|
570
|
+
return cls(base_url, auth=BasicAuth(username, password), **kwargs)
|
|
571
|
+
|
|
572
|
+
@classmethod
|
|
573
|
+
def from_oauth2_client_credentials(
|
|
574
|
+
cls,
|
|
575
|
+
base_url: str,
|
|
576
|
+
token_url: str,
|
|
577
|
+
client_id: str,
|
|
578
|
+
client_secret: str,
|
|
579
|
+
scope: str | None = None,
|
|
580
|
+
**kwargs: Any,
|
|
581
|
+
) -> AsyncClientBase:
|
|
582
|
+
"""Create a client with OAuth2 client credentials authentication."""
|
|
583
|
+
from .auth import OAuth2ClientCredentialsAuth
|
|
584
|
+
return cls(
|
|
585
|
+
base_url,
|
|
586
|
+
auth=OAuth2ClientCredentialsAuth(token_url, client_id, client_secret, scope),
|
|
587
|
+
**kwargs
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
@overload
|
|
591
|
+
async def _make_request(
|
|
592
|
+
self,
|
|
593
|
+
method: str,
|
|
594
|
+
path: str,
|
|
595
|
+
*,
|
|
596
|
+
params: dict[str, Any] | None = None,
|
|
597
|
+
json_data: dict[str, Any] | None = None,
|
|
598
|
+
json_model: None = None,
|
|
599
|
+
response_model: type[T],
|
|
600
|
+
) -> ApiResponse[T]: ...
|
|
601
|
+
|
|
602
|
+
@overload
|
|
603
|
+
async def _make_request(
|
|
604
|
+
self,
|
|
605
|
+
method: str,
|
|
606
|
+
path: str,
|
|
607
|
+
*,
|
|
608
|
+
params: dict[str, Any] | None = None,
|
|
609
|
+
json_data: dict[str, Any] | None = None,
|
|
610
|
+
json_model: None = None,
|
|
611
|
+
headers_model: BaseModel | None = None,
|
|
612
|
+
response_model: None = None,
|
|
613
|
+
) -> ApiResponse[dict[str, Any]]: ...
|
|
614
|
+
|
|
615
|
+
@overload
|
|
616
|
+
async def _make_request(
|
|
617
|
+
self,
|
|
618
|
+
method: str,
|
|
619
|
+
path: str,
|
|
620
|
+
*,
|
|
621
|
+
params: dict[str, Any] | None = None,
|
|
622
|
+
json_data: dict[str, Any] | None = None,
|
|
623
|
+
json_model: None = None,
|
|
624
|
+
headers_model: BaseModel | None = None,
|
|
625
|
+
response_model: type[Any],
|
|
626
|
+
) -> ApiResponse[dict[str, Any]]: ...
|
|
627
|
+
|
|
628
|
+
@overload
|
|
629
|
+
async def _make_request(
|
|
630
|
+
self,
|
|
631
|
+
method: str,
|
|
632
|
+
path: str,
|
|
633
|
+
*,
|
|
634
|
+
params: dict[str, Any] | None = None,
|
|
635
|
+
json_data: dict[str, Any] | None = None,
|
|
636
|
+
json_model: None = None,
|
|
637
|
+
response_model: str,
|
|
638
|
+
) -> ApiResponse[dict[str, Any]]: ...
|
|
639
|
+
|
|
640
|
+
@overload
|
|
641
|
+
async def _make_request(
|
|
642
|
+
self,
|
|
643
|
+
method: str,
|
|
644
|
+
path: str,
|
|
645
|
+
*,
|
|
646
|
+
params: dict[str, Any] | None = None,
|
|
647
|
+
json_data: None = None,
|
|
648
|
+
json_model: BaseModel,
|
|
649
|
+
headers_model: BaseModel | None = None,
|
|
650
|
+
response_model: type[T],
|
|
651
|
+
) -> ApiResponse[T]: ...
|
|
652
|
+
|
|
653
|
+
@overload
|
|
654
|
+
async def _make_request(
|
|
655
|
+
self,
|
|
656
|
+
method: str,
|
|
657
|
+
path: str,
|
|
658
|
+
*,
|
|
659
|
+
params: dict[str, Any] | None = None,
|
|
660
|
+
json_data: None = None,
|
|
661
|
+
json_model: BaseModel,
|
|
662
|
+
headers_model: BaseModel | None = None,
|
|
663
|
+
response_model: None = None,
|
|
664
|
+
) -> ApiResponse[dict[str, Any]]: ...
|
|
665
|
+
|
|
666
|
+
def _validate_request_params(
|
|
667
|
+
self,
|
|
668
|
+
json_data: dict[str, Any] | None,
|
|
669
|
+
json_model: BaseModel | None,
|
|
670
|
+
) -> None:
|
|
671
|
+
"""Validate request parameters for conflicts."""
|
|
672
|
+
if json_model is not None and json_data is not None:
|
|
673
|
+
raise ValueError("Cannot specify both json_data and json_model")
|
|
674
|
+
|
|
675
|
+
def _serialize_request_body(self, json_model: BaseModel | int | float | str | bool | list | dict) -> tuple[bytes, dict[str, str]]:
|
|
676
|
+
"""Serialize request body from Pydantic model or primitive type."""
|
|
677
|
+
from .option import ReflectapiOption
|
|
678
|
+
|
|
679
|
+
# Handle primitive types (for untagged unions)
|
|
680
|
+
if not hasattr(json_model, 'model_dump'):
|
|
681
|
+
content = json.dumps(json_model, default=_json_serializer, separators=(',', ':')).encode('utf-8')
|
|
682
|
+
headers = {"Content-Type": "application/json"}
|
|
683
|
+
return content, headers
|
|
684
|
+
|
|
685
|
+
# Check if model has any ReflectapiOption fields that need special handling
|
|
686
|
+
raw_data = json_model.model_dump(exclude_none=False)
|
|
687
|
+
|
|
688
|
+
# Handle case where RootModel serializes to primitive value (e.g., strings for unit variants)
|
|
689
|
+
if not isinstance(raw_data, dict):
|
|
690
|
+
# For primitive values, use Pydantic's built-in JSON serialization
|
|
691
|
+
content = json_model.model_dump_json(exclude_none=True, by_alias=True).encode('utf-8')
|
|
692
|
+
headers = {"Content-Type": "application/json"}
|
|
693
|
+
return content, headers
|
|
694
|
+
|
|
695
|
+
has_reflectapi_options = any(
|
|
696
|
+
isinstance(field_value, ReflectapiOption)
|
|
697
|
+
for field_value in raw_data.values()
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
if has_reflectapi_options:
|
|
701
|
+
# Process each field to handle ReflectapiOption properly
|
|
702
|
+
processed_fields = {}
|
|
703
|
+
for field_name, field_value in raw_data.items():
|
|
704
|
+
if isinstance(field_value, ReflectapiOption):
|
|
705
|
+
if not field_value.is_undefined:
|
|
706
|
+
# Include the unwrapped value (including None for explicit null)
|
|
707
|
+
processed_fields[field_name] = field_value._value
|
|
708
|
+
# Skip undefined fields entirely - don't include them at all
|
|
709
|
+
else:
|
|
710
|
+
# Include all other fields that aren't None (unless they're meaningful None values)
|
|
711
|
+
if field_value is not None:
|
|
712
|
+
processed_fields[field_name] = field_value
|
|
713
|
+
|
|
714
|
+
# Use json serialization with datetime handler for proper serialization
|
|
715
|
+
content = json.dumps(processed_fields, default=_json_serializer, separators=(',', ':')).encode('utf-8')
|
|
716
|
+
else:
|
|
717
|
+
# Use Pydantic's built-in JSON serialization with exclude_none and by_alias for proper handling
|
|
718
|
+
content = json_model.model_dump_json(exclude_none=True, by_alias=True).encode('utf-8')
|
|
719
|
+
|
|
720
|
+
headers = {"Content-Type": "application/json"}
|
|
721
|
+
|
|
722
|
+
return content, headers
|
|
723
|
+
|
|
724
|
+
def _build_headers(self, base_headers: dict[str, str], headers_model: BaseModel | None) -> dict[str, str]:
|
|
725
|
+
"""Build complete headers dict including custom headers from headers_model."""
|
|
726
|
+
headers = base_headers.copy()
|
|
727
|
+
|
|
728
|
+
# Add headers from headers_model if provided
|
|
729
|
+
if headers_model is not None:
|
|
730
|
+
header_dict = headers_model.model_dump(by_alias=True, exclude_unset=True)
|
|
731
|
+
for key, value in header_dict.items():
|
|
732
|
+
if value is not None:
|
|
733
|
+
headers[key] = str(value)
|
|
734
|
+
|
|
735
|
+
return headers
|
|
736
|
+
|
|
737
|
+
def _build_request(
|
|
738
|
+
self,
|
|
739
|
+
method: str,
|
|
740
|
+
url: str,
|
|
741
|
+
params: dict[str, Any] | None,
|
|
742
|
+
json_data: dict[str, Any] | None,
|
|
743
|
+
json_model: BaseModel | None,
|
|
744
|
+
headers_model: BaseModel | None,
|
|
745
|
+
) -> httpx.Request:
|
|
746
|
+
"""Build HTTP request object."""
|
|
747
|
+
if json_model is not None:
|
|
748
|
+
# Serialize Pydantic model
|
|
749
|
+
content, base_headers = self._serialize_request_body(json_model)
|
|
750
|
+
headers = self._build_headers(base_headers, headers_model)
|
|
751
|
+
|
|
752
|
+
return self._client.build_request(
|
|
753
|
+
method=method,
|
|
754
|
+
url=url,
|
|
755
|
+
params=params,
|
|
756
|
+
content=content,
|
|
757
|
+
headers=headers,
|
|
758
|
+
)
|
|
759
|
+
else:
|
|
760
|
+
# Handle JSON data with Option types
|
|
761
|
+
if json_data is not None:
|
|
762
|
+
# Only serialize Option types for dictionaries (complex types)
|
|
763
|
+
# Primitive types (int, str, bool, etc.) should be passed directly
|
|
764
|
+
if isinstance(json_data, dict):
|
|
765
|
+
processed_json_data = serialize_option_dict(json_data)
|
|
766
|
+
else:
|
|
767
|
+
# Primitive types - pass through directly
|
|
768
|
+
processed_json_data = json_data
|
|
769
|
+
else:
|
|
770
|
+
processed_json_data = json_data
|
|
771
|
+
|
|
772
|
+
# Build headers for requests without json_model
|
|
773
|
+
headers = self._build_headers({}, headers_model)
|
|
774
|
+
|
|
775
|
+
return self._client.build_request(
|
|
776
|
+
method=method,
|
|
777
|
+
url=url,
|
|
778
|
+
params=params,
|
|
779
|
+
json=processed_json_data,
|
|
780
|
+
headers=headers if headers else None,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
async def _execute_request(self, request: httpx.Request) -> httpx.Response:
|
|
784
|
+
"""Execute HTTP request through middleware chain."""
|
|
785
|
+
if self.middleware_chain.middleware:
|
|
786
|
+
return await self.middleware_chain.execute(request, self._client)
|
|
787
|
+
else:
|
|
788
|
+
return await self._client.send(request)
|
|
789
|
+
|
|
790
|
+
def _handle_error_response(self, response: httpx.Response, metadata: TransportMetadata) -> None:
|
|
791
|
+
"""Handle HTTP error responses (4xx, 5xx)."""
|
|
792
|
+
if response.status_code >= 400:
|
|
793
|
+
error_data = None
|
|
794
|
+
try:
|
|
795
|
+
error_data = response.json()
|
|
796
|
+
except Exception:
|
|
797
|
+
pass
|
|
798
|
+
|
|
799
|
+
raise ApplicationError.from_response(response, metadata, error_data)
|
|
800
|
+
|
|
801
|
+
def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]:
|
|
802
|
+
"""Parse JSON response with error handling."""
|
|
803
|
+
try:
|
|
804
|
+
return response.json()
|
|
805
|
+
except Exception as e:
|
|
806
|
+
raise ValidationError(
|
|
807
|
+
f"Failed to parse JSON response: {e}",
|
|
808
|
+
cause=e,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
def _validate_response_model(
|
|
812
|
+
self,
|
|
813
|
+
response: httpx.Response,
|
|
814
|
+
response_model: type[T] | type[Any] | str | _NoValidation,
|
|
815
|
+
metadata: TransportMetadata,
|
|
816
|
+
) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
|
|
817
|
+
"""Validate response using Pydantic model."""
|
|
818
|
+
# Handle special cases where no validation is needed
|
|
819
|
+
if response_model == "Any" or response_model is NO_VALIDATION:
|
|
820
|
+
json_response = self._parse_json_response(response)
|
|
821
|
+
return ApiResponse(json_response, metadata)
|
|
822
|
+
|
|
823
|
+
# Handle typing.Any
|
|
824
|
+
try:
|
|
825
|
+
if response_model is Any:
|
|
826
|
+
json_response = self._parse_json_response(response)
|
|
827
|
+
return ApiResponse(json_response, metadata)
|
|
828
|
+
except Exception:
|
|
829
|
+
# If there's any issue with the comparison, continue with validation
|
|
830
|
+
pass
|
|
831
|
+
|
|
832
|
+
try:
|
|
833
|
+
# Handle Union types (like MyapiModelOutputPet | None)
|
|
834
|
+
import types
|
|
835
|
+
if hasattr(types, 'UnionType') and isinstance(response_model, types.UnionType):
|
|
836
|
+
json_response = self._parse_json_response(response)
|
|
837
|
+
# For Union types, try to deserialize with each type in the union
|
|
838
|
+
union_args = response_model.__args__
|
|
839
|
+
|
|
840
|
+
# Handle None case first
|
|
841
|
+
if json_response is None and type(None) in union_args:
|
|
842
|
+
return ApiResponse(None, metadata)
|
|
843
|
+
|
|
844
|
+
# Try each non-None type in the union
|
|
845
|
+
for arg_type in union_args:
|
|
846
|
+
if arg_type is not type(None) and hasattr(arg_type, "model_validate"):
|
|
847
|
+
try:
|
|
848
|
+
validated_data = arg_type.model_validate(json_response)
|
|
849
|
+
return ApiResponse(validated_data, metadata)
|
|
850
|
+
except Exception:
|
|
851
|
+
continue # Try next type
|
|
852
|
+
|
|
853
|
+
# If none of the types worked, return as dict
|
|
854
|
+
return ApiResponse(json_response, metadata)
|
|
855
|
+
|
|
856
|
+
# Type guard to ensure we have a model with validation methods
|
|
857
|
+
if not (isinstance(response_model, type) and hasattr(response_model, "model_validate")):
|
|
858
|
+
# Shouldn't happen, but fallback to JSON parsing
|
|
859
|
+
json_response = self._parse_json_response(response)
|
|
860
|
+
return ApiResponse(json_response, metadata)
|
|
861
|
+
|
|
862
|
+
# Use model_validate_json for high-performance parsing
|
|
863
|
+
if hasattr(response_model, "model_validate_json"):
|
|
864
|
+
content = response.content
|
|
865
|
+
# In tests/mocked responses, content may not be bytes/str; fall back to parsed JSON
|
|
866
|
+
if not isinstance(content, (bytes, bytearray, str)):
|
|
867
|
+
json_response = self._parse_json_response(response)
|
|
868
|
+
validated_data = response_model.model_validate(json_response)
|
|
869
|
+
else:
|
|
870
|
+
validated_data = response_model.model_validate_json(content)
|
|
871
|
+
else:
|
|
872
|
+
# Fallback to old method for compatibility
|
|
873
|
+
json_response = self._parse_json_response(response)
|
|
874
|
+
validated_data = response_model.model_validate(json_response)
|
|
875
|
+
|
|
876
|
+
return ApiResponse(validated_data, metadata)
|
|
877
|
+
except PydanticValidationError as e:
|
|
878
|
+
raise ValidationError(
|
|
879
|
+
f"Response validation failed: {e}",
|
|
880
|
+
validation_errors=e.errors(),
|
|
881
|
+
cause=e,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
async def _make_request(
|
|
885
|
+
self,
|
|
886
|
+
method: str,
|
|
887
|
+
path: str,
|
|
888
|
+
*,
|
|
889
|
+
params: dict[str, Any] | None = None,
|
|
890
|
+
json_data: dict[str, Any] | None = None,
|
|
891
|
+
json_model: BaseModel | None = None,
|
|
892
|
+
headers_model: BaseModel | None = None,
|
|
893
|
+
response_model: type[T] | type[Any] | str | _NoValidation | None = None,
|
|
894
|
+
) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
|
|
895
|
+
"""Make an HTTP request and return an ApiResponse."""
|
|
896
|
+
# Validate request parameters
|
|
897
|
+
self._validate_request_params(json_data, json_model)
|
|
898
|
+
|
|
899
|
+
# Build URL and request
|
|
900
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
901
|
+
request = self._build_request(method, url, params, json_data, json_model, headers_model)
|
|
902
|
+
|
|
903
|
+
# Execute request with timing
|
|
904
|
+
start_time = time.time()
|
|
905
|
+
|
|
906
|
+
try:
|
|
907
|
+
response = await self._execute_request(request)
|
|
908
|
+
metadata = TransportMetadata.from_response(response, start_time)
|
|
909
|
+
|
|
910
|
+
# Handle error responses
|
|
911
|
+
self._handle_error_response(response, metadata)
|
|
912
|
+
|
|
913
|
+
# Validate and return response
|
|
914
|
+
if response_model is not None:
|
|
915
|
+
return self._validate_response_model(response, response_model, metadata)
|
|
916
|
+
else:
|
|
917
|
+
# No response_model provided - parse JSON into dict
|
|
918
|
+
json_response = self._parse_json_response(response)
|
|
919
|
+
return ApiResponse(json_response, metadata)
|
|
920
|
+
|
|
921
|
+
except httpx.TimeoutException as e:
|
|
922
|
+
raise TimeoutError.from_httpx_timeout(e)
|
|
923
|
+
except httpx.RequestError as e:
|
|
924
|
+
raise NetworkError.from_httpx_error(e)
|