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.
@@ -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)