fixturify 0.1.9__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.
- fixturify/__init__.py +21 -0
- fixturify/_utils/__init__.py +7 -0
- fixturify/_utils/_constants.py +10 -0
- fixturify/_utils/_fixture_discovery.py +165 -0
- fixturify/_utils/_path_resolver.py +135 -0
- fixturify/http_d/__init__.py +80 -0
- fixturify/http_d/_config.py +214 -0
- fixturify/http_d/_decorator.py +267 -0
- fixturify/http_d/_exceptions.py +153 -0
- fixturify/http_d/_fixture_discovery.py +33 -0
- fixturify/http_d/_matcher.py +372 -0
- fixturify/http_d/_mock_context.py +154 -0
- fixturify/http_d/_models.py +205 -0
- fixturify/http_d/_patcher.py +524 -0
- fixturify/http_d/_player.py +222 -0
- fixturify/http_d/_recorder.py +1350 -0
- fixturify/http_d/_stubs/__init__.py +8 -0
- fixturify/http_d/_stubs/_aiohttp.py +220 -0
- fixturify/http_d/_stubs/_connection.py +478 -0
- fixturify/http_d/_stubs/_httpcore.py +269 -0
- fixturify/http_d/_stubs/_tornado.py +95 -0
- fixturify/http_d/_utils.py +194 -0
- fixturify/json_assert/__init__.py +13 -0
- fixturify/json_assert/_actual_saver.py +67 -0
- fixturify/json_assert/_assert.py +173 -0
- fixturify/json_assert/_comparator.py +183 -0
- fixturify/json_assert/_diff_formatter.py +265 -0
- fixturify/json_assert/_normalizer.py +83 -0
- fixturify/object_mapper/__init__.py +5 -0
- fixturify/object_mapper/_deserializers/__init__.py +19 -0
- fixturify/object_mapper/_deserializers/_base.py +186 -0
- fixturify/object_mapper/_deserializers/_dataclass.py +52 -0
- fixturify/object_mapper/_deserializers/_plain.py +55 -0
- fixturify/object_mapper/_deserializers/_pydantic_v1.py +38 -0
- fixturify/object_mapper/_deserializers/_pydantic_v2.py +41 -0
- fixturify/object_mapper/_deserializers/_sqlalchemy.py +72 -0
- fixturify/object_mapper/_deserializers/_sqlmodel.py +43 -0
- fixturify/object_mapper/_detectors/__init__.py +5 -0
- fixturify/object_mapper/_detectors/_type_detector.py +186 -0
- fixturify/object_mapper/_serializers/__init__.py +19 -0
- fixturify/object_mapper/_serializers/_base.py +260 -0
- fixturify/object_mapper/_serializers/_dataclass.py +55 -0
- fixturify/object_mapper/_serializers/_plain.py +49 -0
- fixturify/object_mapper/_serializers/_pydantic_v1.py +49 -0
- fixturify/object_mapper/_serializers/_pydantic_v2.py +49 -0
- fixturify/object_mapper/_serializers/_sqlalchemy.py +70 -0
- fixturify/object_mapper/_serializers/_sqlmodel.py +54 -0
- fixturify/object_mapper/mapper.py +256 -0
- fixturify/read_d/__init__.py +5 -0
- fixturify/read_d/_decorator.py +193 -0
- fixturify/read_d/_fixture_loader.py +88 -0
- fixturify/sql_d/__init__.py +7 -0
- fixturify/sql_d/_config.py +30 -0
- fixturify/sql_d/_decorator.py +373 -0
- fixturify/sql_d/_driver_registry.py +133 -0
- fixturify/sql_d/_executor.py +82 -0
- fixturify/sql_d/_fixture_discovery.py +55 -0
- fixturify/sql_d/_phase.py +10 -0
- fixturify/sql_d/_strategies/__init__.py +11 -0
- fixturify/sql_d/_strategies/_aiomysql.py +63 -0
- fixturify/sql_d/_strategies/_aiosqlite.py +29 -0
- fixturify/sql_d/_strategies/_asyncpg.py +34 -0
- fixturify/sql_d/_strategies/_base.py +118 -0
- fixturify/sql_d/_strategies/_mysql.py +70 -0
- fixturify/sql_d/_strategies/_psycopg.py +35 -0
- fixturify/sql_d/_strategies/_psycopg2.py +40 -0
- fixturify/sql_d/_strategies/_registry.py +109 -0
- fixturify/sql_d/_strategies/_sqlite.py +33 -0
- fixturify-0.1.9.dist-info/METADATA +122 -0
- fixturify-0.1.9.dist-info/RECORD +71 -0
- fixturify-0.1.9.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1350 @@
|
|
|
1
|
+
"""Recording logic for capturing HTTP interactions."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
9
|
+
from urllib.parse import parse_qs, urlparse
|
|
10
|
+
|
|
11
|
+
from fixturify._utils import ENCODING
|
|
12
|
+
|
|
13
|
+
from fixturify.http_d._models import HttpMapping, HttpMappings, HttpRequest, HttpResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Content types that should be stored as separate binary files
|
|
17
|
+
BINARY_CONTENT_TYPES = (
|
|
18
|
+
"image/",
|
|
19
|
+
"audio/",
|
|
20
|
+
"video/",
|
|
21
|
+
"application/octet-stream",
|
|
22
|
+
"application/pdf",
|
|
23
|
+
"application/zip",
|
|
24
|
+
"application/gzip",
|
|
25
|
+
"application/x-tar",
|
|
26
|
+
"application/x-rar",
|
|
27
|
+
"application/x-7z-compressed",
|
|
28
|
+
"application/vnd.ms-excel",
|
|
29
|
+
"application/vnd.openxmlformats",
|
|
30
|
+
"application/msword",
|
|
31
|
+
"font/",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_content_type(content_type: Union[str, bytes, None]) -> str:
|
|
36
|
+
"""Normalize content type to string."""
|
|
37
|
+
if content_type is None:
|
|
38
|
+
return ""
|
|
39
|
+
if isinstance(content_type, bytes):
|
|
40
|
+
return content_type.decode("utf-8", errors="ignore")
|
|
41
|
+
return content_type
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_text_content_type(content_type: Union[str, bytes]) -> bool:
|
|
45
|
+
"""Check if content type indicates text content."""
|
|
46
|
+
text_types = (
|
|
47
|
+
"text/",
|
|
48
|
+
"application/json",
|
|
49
|
+
"application/xml",
|
|
50
|
+
"application/javascript",
|
|
51
|
+
"application/x-www-form-urlencoded",
|
|
52
|
+
)
|
|
53
|
+
content_str = _normalize_content_type(content_type)
|
|
54
|
+
return any(t in content_str.lower() for t in text_types)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_json_content_type(content_type: Union[str, bytes]) -> bool:
|
|
58
|
+
"""Check if content type indicates JSON content."""
|
|
59
|
+
content_str = _normalize_content_type(content_type)
|
|
60
|
+
return "application/json" in content_str.lower()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_binary_content_type(content_type: Union[str, bytes]) -> bool:
|
|
64
|
+
"""Check if content type indicates binary content that should be stored as file."""
|
|
65
|
+
content_str = _normalize_content_type(content_type)
|
|
66
|
+
content_lower = content_str.lower()
|
|
67
|
+
return any(t in content_lower for t in BINARY_CONTENT_TYPES)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _try_parse_json(content: str) -> Any:
|
|
71
|
+
"""Try to parse string as JSON, return parsed or original string."""
|
|
72
|
+
try:
|
|
73
|
+
return json.loads(content)
|
|
74
|
+
except (json.JSONDecodeError, TypeError):
|
|
75
|
+
return content
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_file_extension(content_type: Union[str, bytes]) -> str:
|
|
79
|
+
"""Get appropriate file extension for a content type."""
|
|
80
|
+
# Common mappings
|
|
81
|
+
extension_map = {
|
|
82
|
+
"application/json": ".json",
|
|
83
|
+
"application/pdf": ".pdf",
|
|
84
|
+
"application/zip": ".zip",
|
|
85
|
+
"application/gzip": ".gz",
|
|
86
|
+
"application/xml": ".xml",
|
|
87
|
+
"text/html": ".html",
|
|
88
|
+
"text/plain": ".txt",
|
|
89
|
+
"text/css": ".css",
|
|
90
|
+
"text/javascript": ".js",
|
|
91
|
+
"image/jpeg": ".jpg",
|
|
92
|
+
"image/png": ".png",
|
|
93
|
+
"image/gif": ".gif",
|
|
94
|
+
"image/webp": ".webp",
|
|
95
|
+
"image/svg+xml": ".svg",
|
|
96
|
+
"audio/mpeg": ".mp3",
|
|
97
|
+
"audio/wav": ".wav",
|
|
98
|
+
"video/mp4": ".mp4",
|
|
99
|
+
"video/webm": ".webm",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Normalize content_type to string
|
|
103
|
+
content_str = _normalize_content_type(content_type)
|
|
104
|
+
|
|
105
|
+
# Check our map first
|
|
106
|
+
content_lower = content_str.lower().split(";")[0].strip()
|
|
107
|
+
if content_lower in extension_map:
|
|
108
|
+
return extension_map[content_lower]
|
|
109
|
+
|
|
110
|
+
# Fall back to mimetypes
|
|
111
|
+
ext = mimetypes.guess_extension(content_lower)
|
|
112
|
+
if ext:
|
|
113
|
+
return ext
|
|
114
|
+
|
|
115
|
+
# Default to .bin for unknown binary
|
|
116
|
+
return ".bin"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _serialize_body(
|
|
120
|
+
body: Optional[Union[bytes, str]],
|
|
121
|
+
content_type: Union[str, bytes] = "",
|
|
122
|
+
) -> Tuple[Any, Optional[str], bool]:
|
|
123
|
+
"""
|
|
124
|
+
Serialize body for storage.
|
|
125
|
+
|
|
126
|
+
For JSON content: returns parsed JSON object (not stringified)
|
|
127
|
+
For text content: returns string
|
|
128
|
+
For binary content: returns raw bytes and flag to save as file
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
body: Request or response body (bytes, string, or BytesIO)
|
|
132
|
+
content_type: Content-Type header value (str or bytes)
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Tuple of (body_value, encoding, is_binary_file)
|
|
136
|
+
- For text/JSON: (value, None, False)
|
|
137
|
+
- For binary: (bytes, None, True)
|
|
138
|
+
"""
|
|
139
|
+
if body is None:
|
|
140
|
+
return None, None, False
|
|
141
|
+
|
|
142
|
+
# Handle BytesIO and other file-like objects
|
|
143
|
+
if hasattr(body, "read"):
|
|
144
|
+
file_obj = body
|
|
145
|
+
body = file_obj.read()
|
|
146
|
+
# Reset position if possible for re-reading
|
|
147
|
+
if hasattr(file_obj, "seek"):
|
|
148
|
+
file_obj.seek(0)
|
|
149
|
+
|
|
150
|
+
# Normalize content_type to string
|
|
151
|
+
content_type = _normalize_content_type(content_type)
|
|
152
|
+
|
|
153
|
+
# Check if this should be stored as a binary file
|
|
154
|
+
if _is_binary_content_type(content_type):
|
|
155
|
+
if isinstance(body, bytes):
|
|
156
|
+
return body, None, True
|
|
157
|
+
elif isinstance(body, str):
|
|
158
|
+
return body.encode("utf-8"), None, True
|
|
159
|
+
else:
|
|
160
|
+
# Unknown type - try to convert
|
|
161
|
+
return str(body).encode("utf-8"), None, True
|
|
162
|
+
|
|
163
|
+
# Convert bytes to string if needed
|
|
164
|
+
if isinstance(body, bytes):
|
|
165
|
+
if _is_text_content_type(content_type) or not content_type:
|
|
166
|
+
try:
|
|
167
|
+
body_str = body.decode("utf-8")
|
|
168
|
+
except UnicodeDecodeError:
|
|
169
|
+
# Binary content - return as bytes for file storage
|
|
170
|
+
return body, None, True
|
|
171
|
+
else:
|
|
172
|
+
# Unknown content type with bytes - try text first
|
|
173
|
+
try:
|
|
174
|
+
body_str = body.decode("utf-8")
|
|
175
|
+
except UnicodeDecodeError:
|
|
176
|
+
return body, None, True
|
|
177
|
+
else:
|
|
178
|
+
body_str = body
|
|
179
|
+
|
|
180
|
+
# Empty body
|
|
181
|
+
if not body_str:
|
|
182
|
+
return None, None, False
|
|
183
|
+
|
|
184
|
+
# For JSON content, parse and store as native JSON
|
|
185
|
+
if _is_json_content_type(content_type):
|
|
186
|
+
return _try_parse_json(body_str), None, False
|
|
187
|
+
|
|
188
|
+
# Return as string for non-JSON text content
|
|
189
|
+
return body_str, None, False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class HttpRecorder:
|
|
193
|
+
"""
|
|
194
|
+
Records HTTP interactions for later playback.
|
|
195
|
+
|
|
196
|
+
Captures request/response pairs and saves them to a JSON file
|
|
197
|
+
in WireMock-compatible format. Binary responses are stored as
|
|
198
|
+
separate files in a __files subdirectory.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
# Directory name for binary response files (WireMock convention)
|
|
202
|
+
FILES_DIR = "__files"
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
file_path: Path,
|
|
207
|
+
exclude_request_headers: Optional[set] = None,
|
|
208
|
+
exclude_response_headers: Optional[set] = None,
|
|
209
|
+
redact_request_body: Optional[list[str]] = None,
|
|
210
|
+
redact_response_body: Optional[list[str]] = None,
|
|
211
|
+
):
|
|
212
|
+
"""
|
|
213
|
+
Initialize the recorder.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
file_path: Path where recordings will be saved.
|
|
217
|
+
exclude_request_headers: Set of header names (lowercase) to exclude
|
|
218
|
+
from request recordings.
|
|
219
|
+
exclude_response_headers: Set of header names (lowercase) to exclude
|
|
220
|
+
from response recordings.
|
|
221
|
+
"""
|
|
222
|
+
self.file_path = file_path
|
|
223
|
+
self.mappings: HttpMappings = HttpMappings()
|
|
224
|
+
# Track binary files to save: list of (filename, bytes)
|
|
225
|
+
self._binary_files: List[Tuple[str, bytes]] = []
|
|
226
|
+
# Counter for generating unique filenames
|
|
227
|
+
self._file_counter = 0
|
|
228
|
+
# Headers to exclude from recordings
|
|
229
|
+
self._exclude_request_headers = exclude_request_headers or set()
|
|
230
|
+
self._exclude_response_headers = exclude_response_headers or set()
|
|
231
|
+
self._redact_request_body = {f for f in (redact_request_body or []) if f}
|
|
232
|
+
self._redact_response_body = {f for f in (redact_response_body or []) if f}
|
|
233
|
+
|
|
234
|
+
def _redact_json_fields(self, value: Any) -> Any:
|
|
235
|
+
"""Redact configured JSON fields in dict/list structures."""
|
|
236
|
+
if not self._redact_request_body:
|
|
237
|
+
return value
|
|
238
|
+
if isinstance(value, dict):
|
|
239
|
+
redacted = {}
|
|
240
|
+
for key, val in value.items():
|
|
241
|
+
if key in self._redact_request_body:
|
|
242
|
+
redacted[key] = "*******"
|
|
243
|
+
else:
|
|
244
|
+
redacted[key] = self._redact_json_fields(val)
|
|
245
|
+
return redacted
|
|
246
|
+
if isinstance(value, list):
|
|
247
|
+
return [self._redact_json_fields(item) for item in value]
|
|
248
|
+
return value
|
|
249
|
+
|
|
250
|
+
def _redact_json_fields_response(self, value: Any) -> Any:
|
|
251
|
+
"""Redact configured JSON fields in dict/list structures (response)."""
|
|
252
|
+
if not self._redact_response_body:
|
|
253
|
+
return value
|
|
254
|
+
if isinstance(value, dict):
|
|
255
|
+
redacted = {}
|
|
256
|
+
for key, val in value.items():
|
|
257
|
+
if key in self._redact_response_body:
|
|
258
|
+
redacted[key] = "*******"
|
|
259
|
+
else:
|
|
260
|
+
redacted[key] = self._redact_json_fields_response(val)
|
|
261
|
+
return redacted
|
|
262
|
+
if isinstance(value, list):
|
|
263
|
+
return [self._redact_json_fields_response(item) for item in value]
|
|
264
|
+
return value
|
|
265
|
+
|
|
266
|
+
def record(
|
|
267
|
+
self,
|
|
268
|
+
request: HttpRequest,
|
|
269
|
+
response: HttpResponse,
|
|
270
|
+
) -> None:
|
|
271
|
+
"""
|
|
272
|
+
Record a request/response pair.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
request: The HTTP request.
|
|
276
|
+
response: The HTTP response.
|
|
277
|
+
"""
|
|
278
|
+
mapping = HttpMapping(request=request, response=response)
|
|
279
|
+
self.mappings.add(mapping)
|
|
280
|
+
|
|
281
|
+
def save(self) -> None:
|
|
282
|
+
"""
|
|
283
|
+
Save all recorded mappings to the JSON file.
|
|
284
|
+
|
|
285
|
+
Creates parent directories if they don't exist.
|
|
286
|
+
Binary response bodies are saved to a __files subdirectory.
|
|
287
|
+
"""
|
|
288
|
+
# Ensure parent directory exists
|
|
289
|
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
290
|
+
|
|
291
|
+
# Convert to dict with native JSON bodies (and save binary files)
|
|
292
|
+
data = self._to_dict_with_json_bodies()
|
|
293
|
+
|
|
294
|
+
# Write JSON file
|
|
295
|
+
with open(self.file_path, "w", encoding=ENCODING) as f:
|
|
296
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
297
|
+
|
|
298
|
+
# Save binary files
|
|
299
|
+
if self._binary_files:
|
|
300
|
+
files_dir = self.file_path.parent / self.FILES_DIR
|
|
301
|
+
files_dir.mkdir(exist_ok=True)
|
|
302
|
+
|
|
303
|
+
for filename, content in self._binary_files:
|
|
304
|
+
file_path = files_dir / filename
|
|
305
|
+
with open(file_path, "wb") as f:
|
|
306
|
+
f.write(content)
|
|
307
|
+
|
|
308
|
+
def _generate_filename(self, content_type: str, content: bytes) -> str:
|
|
309
|
+
"""
|
|
310
|
+
Generate a unique filename for binary content.
|
|
311
|
+
|
|
312
|
+
Uses a hash of the content to enable deduplication.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
content_type: Content-Type header
|
|
316
|
+
content: Binary content
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Filename like "response_0_a1b2c3d4.png"
|
|
320
|
+
"""
|
|
321
|
+
self._file_counter += 1
|
|
322
|
+
ext = _get_file_extension(content_type)
|
|
323
|
+
# Use content hash for deduplication and uniqueness
|
|
324
|
+
content_hash = hashlib.sha256(content).hexdigest()[:8]
|
|
325
|
+
return f"response_{self._file_counter}_{content_hash}{ext}"
|
|
326
|
+
|
|
327
|
+
def _filter_headers(
|
|
328
|
+
self,
|
|
329
|
+
headers: Dict[str, str],
|
|
330
|
+
exclude_set: set,
|
|
331
|
+
) -> Dict[str, str]:
|
|
332
|
+
"""
|
|
333
|
+
Filter headers, removing excluded ones.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
headers: Original headers dict.
|
|
337
|
+
exclude_set: Set of lowercase header names to exclude.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Filtered headers dict.
|
|
341
|
+
"""
|
|
342
|
+
if not exclude_set:
|
|
343
|
+
return headers
|
|
344
|
+
return {k: v for k, v in headers.items() if k.lower() not in exclude_set}
|
|
345
|
+
|
|
346
|
+
def _to_dict_with_json_bodies(self) -> Dict[str, Any]:
|
|
347
|
+
"""
|
|
348
|
+
Convert mappings to dict, storing bodies as native JSON.
|
|
349
|
+
|
|
350
|
+
Binary response bodies are saved to files and referenced via bodyFileName.
|
|
351
|
+
Excluded headers are filtered out from the recording.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Dict ready for JSON serialization.
|
|
355
|
+
"""
|
|
356
|
+
mappings_list = []
|
|
357
|
+
self._binary_files.clear()
|
|
358
|
+
self._file_counter = 0
|
|
359
|
+
|
|
360
|
+
for mapping in self.mappings:
|
|
361
|
+
# Filter headers
|
|
362
|
+
req_headers = self._filter_headers(
|
|
363
|
+
mapping.request.headers,
|
|
364
|
+
self._exclude_request_headers,
|
|
365
|
+
)
|
|
366
|
+
resp_headers = self._filter_headers(
|
|
367
|
+
mapping.response.headers,
|
|
368
|
+
self._exclude_response_headers,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Get content types for body serialization
|
|
372
|
+
req_content_type = req_headers.get(
|
|
373
|
+
"Content-Type",
|
|
374
|
+
req_headers.get("content-type", ""),
|
|
375
|
+
)
|
|
376
|
+
resp_content_type = resp_headers.get(
|
|
377
|
+
"Content-Type",
|
|
378
|
+
resp_headers.get("content-type", ""),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Serialize request body
|
|
382
|
+
req_body = mapping.request.body
|
|
383
|
+
if isinstance(req_body, str) and _is_json_content_type(req_content_type):
|
|
384
|
+
req_body = _try_parse_json(req_body)
|
|
385
|
+
|
|
386
|
+
# Build request dict
|
|
387
|
+
if isinstance(req_body, (dict, list)):
|
|
388
|
+
req_body = self._redact_json_fields(req_body)
|
|
389
|
+
request_dict: Dict[str, Any] = {
|
|
390
|
+
"method": mapping.request.method.upper(),
|
|
391
|
+
"url": mapping.request.url,
|
|
392
|
+
"headers": req_headers,
|
|
393
|
+
"body": req_body,
|
|
394
|
+
}
|
|
395
|
+
if mapping.request.bodyEncoding:
|
|
396
|
+
request_dict["bodyEncoding"] = mapping.request.bodyEncoding
|
|
397
|
+
|
|
398
|
+
# Build response dict
|
|
399
|
+
response_dict: Dict[str, Any] = {
|
|
400
|
+
"status": mapping.response.status,
|
|
401
|
+
"headers": resp_headers,
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
# Check if response has a binary file reference
|
|
405
|
+
if mapping.response.bodyFileName:
|
|
406
|
+
response_dict["bodyFileName"] = mapping.response.bodyFileName
|
|
407
|
+
elif mapping.response._body_bytes is not None:
|
|
408
|
+
# Binary content that needs to be saved as file
|
|
409
|
+
filename = self._generate_filename(
|
|
410
|
+
resp_content_type,
|
|
411
|
+
mapping.response._body_bytes,
|
|
412
|
+
)
|
|
413
|
+
self._binary_files.append((filename, mapping.response._body_bytes))
|
|
414
|
+
response_dict["bodyFileName"] = filename
|
|
415
|
+
elif _is_binary_content_type(resp_content_type) and mapping.response.body:
|
|
416
|
+
# Response body is base64-encoded binary - decode and save as file
|
|
417
|
+
try:
|
|
418
|
+
content = base64.b64decode(mapping.response.body)
|
|
419
|
+
filename = self._generate_filename(resp_content_type, content)
|
|
420
|
+
self._binary_files.append((filename, content))
|
|
421
|
+
response_dict["bodyFileName"] = filename
|
|
422
|
+
except Exception:
|
|
423
|
+
# Fall back to inline body
|
|
424
|
+
resp_body = mapping.response.body
|
|
425
|
+
if _is_json_content_type(resp_content_type):
|
|
426
|
+
resp_body = _try_parse_json(resp_body)
|
|
427
|
+
response_dict["body"] = resp_body
|
|
428
|
+
else:
|
|
429
|
+
# Text/JSON content - store inline
|
|
430
|
+
resp_body = mapping.response.body
|
|
431
|
+
if isinstance(resp_body, str) and _is_json_content_type(
|
|
432
|
+
resp_content_type
|
|
433
|
+
):
|
|
434
|
+
resp_body = _try_parse_json(resp_body)
|
|
435
|
+
if isinstance(resp_body, (dict, list)):
|
|
436
|
+
resp_body = self._redact_json_fields_response(resp_body)
|
|
437
|
+
response_dict["body"] = resp_body if resp_body else None
|
|
438
|
+
|
|
439
|
+
if mapping.response.bodyEncoding and "bodyFileName" not in response_dict:
|
|
440
|
+
response_dict["bodyEncoding"] = mapping.response.bodyEncoding
|
|
441
|
+
|
|
442
|
+
mappings_list.append(
|
|
443
|
+
{
|
|
444
|
+
"request": request_dict,
|
|
445
|
+
"response": response_dict,
|
|
446
|
+
}
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
return {"mappings": mappings_list}
|
|
450
|
+
|
|
451
|
+
def __len__(self) -> int:
|
|
452
|
+
"""Return number of recorded mappings."""
|
|
453
|
+
return len(self.mappings)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def create_request_from_httpx(
|
|
457
|
+
request: Any, # httpx.Request
|
|
458
|
+
) -> HttpRequest:
|
|
459
|
+
"""
|
|
460
|
+
Create HttpRequest from an httpx Request object.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
request: httpx.Request object
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
HttpRequest model
|
|
467
|
+
"""
|
|
468
|
+
# Get URL and extract query parameters
|
|
469
|
+
url_str = str(request.url)
|
|
470
|
+
parsed = urlparse(url_str)
|
|
471
|
+
|
|
472
|
+
# Extract query parameters
|
|
473
|
+
query_params: Dict[str, str] = {}
|
|
474
|
+
if parsed.query:
|
|
475
|
+
for key, values in parse_qs(parsed.query).items():
|
|
476
|
+
query_params[key] = values[0] if values else ""
|
|
477
|
+
|
|
478
|
+
# Get headers as dict
|
|
479
|
+
headers = dict(request.headers)
|
|
480
|
+
|
|
481
|
+
# Get body
|
|
482
|
+
content_type = headers.get("content-type", headers.get("Content-Type", ""))
|
|
483
|
+
body_bytes = request.content if hasattr(request, "content") else None
|
|
484
|
+
body, body_encoding, _ = _serialize_body(body_bytes, content_type)
|
|
485
|
+
|
|
486
|
+
# Convert body back to string for storage in model (will be parsed when saving)
|
|
487
|
+
body_str = None
|
|
488
|
+
if body is not None:
|
|
489
|
+
if body_encoding == "base64":
|
|
490
|
+
body_str = body
|
|
491
|
+
elif isinstance(body, str):
|
|
492
|
+
body_str = body
|
|
493
|
+
else:
|
|
494
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
495
|
+
|
|
496
|
+
return HttpRequest(
|
|
497
|
+
method=request.method,
|
|
498
|
+
url=url_str,
|
|
499
|
+
headers=headers,
|
|
500
|
+
queryParameters=query_params,
|
|
501
|
+
body=body_str,
|
|
502
|
+
bodyEncoding=body_encoding,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def create_response_from_httpx(
|
|
507
|
+
response: Any, # httpx.Response
|
|
508
|
+
) -> HttpResponse:
|
|
509
|
+
"""
|
|
510
|
+
Create HttpResponse from an httpx Response object.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
response: httpx.Response object
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
HttpResponse model
|
|
517
|
+
"""
|
|
518
|
+
headers = dict(response.headers)
|
|
519
|
+
content_type = headers.get("content-type", headers.get("Content-Type", ""))
|
|
520
|
+
|
|
521
|
+
# Get response body
|
|
522
|
+
body_bytes = response.content
|
|
523
|
+
body, body_encoding, is_binary = _serialize_body(body_bytes, content_type)
|
|
524
|
+
|
|
525
|
+
if is_binary:
|
|
526
|
+
# Binary content - store bytes for file saving
|
|
527
|
+
resp = HttpResponse(
|
|
528
|
+
status=response.status_code,
|
|
529
|
+
headers=headers,
|
|
530
|
+
body=None,
|
|
531
|
+
)
|
|
532
|
+
resp._body_bytes = body if isinstance(body, bytes) else body_bytes
|
|
533
|
+
return resp
|
|
534
|
+
|
|
535
|
+
# Convert body to string for storage in model
|
|
536
|
+
body_str = ""
|
|
537
|
+
if body is not None:
|
|
538
|
+
if isinstance(body, str):
|
|
539
|
+
body_str = body
|
|
540
|
+
else:
|
|
541
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
542
|
+
|
|
543
|
+
return HttpResponse(
|
|
544
|
+
status=response.status_code,
|
|
545
|
+
headers=headers,
|
|
546
|
+
body=body_str,
|
|
547
|
+
bodyEncoding=body_encoding,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def create_request_from_requests(
|
|
552
|
+
prepared_request: Any, # requests.PreparedRequest
|
|
553
|
+
) -> HttpRequest:
|
|
554
|
+
"""
|
|
555
|
+
Create HttpRequest from a requests PreparedRequest object.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
prepared_request: requests.PreparedRequest object
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
HttpRequest model
|
|
562
|
+
"""
|
|
563
|
+
url_str = prepared_request.url or ""
|
|
564
|
+
parsed = urlparse(url_str)
|
|
565
|
+
|
|
566
|
+
# Extract query parameters
|
|
567
|
+
query_params: Dict[str, str] = {}
|
|
568
|
+
if parsed.query:
|
|
569
|
+
for key, values in parse_qs(parsed.query).items():
|
|
570
|
+
query_params[key] = values[0] if values else ""
|
|
571
|
+
|
|
572
|
+
# Get headers as dict
|
|
573
|
+
headers = dict(prepared_request.headers) if prepared_request.headers else {}
|
|
574
|
+
|
|
575
|
+
# Get body
|
|
576
|
+
content_type = headers.get("content-type", headers.get("Content-Type", ""))
|
|
577
|
+
body_raw = prepared_request.body
|
|
578
|
+
body, body_encoding, _ = _serialize_body(body_raw, content_type)
|
|
579
|
+
|
|
580
|
+
# Convert body back to string for storage
|
|
581
|
+
body_str = None
|
|
582
|
+
if body is not None:
|
|
583
|
+
if body_encoding == "base64":
|
|
584
|
+
body_str = body
|
|
585
|
+
elif isinstance(body, str):
|
|
586
|
+
body_str = body
|
|
587
|
+
else:
|
|
588
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
589
|
+
|
|
590
|
+
return HttpRequest(
|
|
591
|
+
method=prepared_request.method or "GET",
|
|
592
|
+
url=url_str,
|
|
593
|
+
headers=headers,
|
|
594
|
+
queryParameters=query_params,
|
|
595
|
+
body=body_str,
|
|
596
|
+
bodyEncoding=body_encoding,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def create_response_from_requests(
|
|
601
|
+
response: Any, # requests.Response
|
|
602
|
+
) -> HttpResponse:
|
|
603
|
+
"""
|
|
604
|
+
Create HttpResponse from a requests Response object.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
response: requests.Response object
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
HttpResponse model
|
|
611
|
+
"""
|
|
612
|
+
headers = dict(response.headers) if response.headers else {}
|
|
613
|
+
content_type = headers.get("content-type", headers.get("Content-Type", ""))
|
|
614
|
+
|
|
615
|
+
# Get response body
|
|
616
|
+
body_bytes = response.content
|
|
617
|
+
body, body_encoding, is_binary = _serialize_body(body_bytes, content_type)
|
|
618
|
+
|
|
619
|
+
if is_binary:
|
|
620
|
+
# Binary content - store bytes for file saving
|
|
621
|
+
resp = HttpResponse(
|
|
622
|
+
status=response.status_code,
|
|
623
|
+
headers=headers,
|
|
624
|
+
body=None,
|
|
625
|
+
)
|
|
626
|
+
resp._body_bytes = body if isinstance(body, bytes) else body_bytes
|
|
627
|
+
return resp
|
|
628
|
+
|
|
629
|
+
# Convert body to string for storage
|
|
630
|
+
body_str = ""
|
|
631
|
+
if body is not None:
|
|
632
|
+
if isinstance(body, str):
|
|
633
|
+
body_str = body
|
|
634
|
+
else:
|
|
635
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
636
|
+
|
|
637
|
+
return HttpResponse(
|
|
638
|
+
status=response.status_code,
|
|
639
|
+
headers=headers,
|
|
640
|
+
body=body_str,
|
|
641
|
+
bodyEncoding=body_encoding,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# ============================================================================
|
|
646
|
+
# urllib3 functions
|
|
647
|
+
# ============================================================================
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def create_request_from_urllib3(
|
|
651
|
+
method: str,
|
|
652
|
+
url: str,
|
|
653
|
+
body: Any = None,
|
|
654
|
+
headers: Any = None,
|
|
655
|
+
) -> HttpRequest:
|
|
656
|
+
"""
|
|
657
|
+
Create HttpRequest from urllib3 request parameters.
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
method: HTTP method
|
|
661
|
+
url: Full URL string
|
|
662
|
+
body: Request body
|
|
663
|
+
headers: Request headers
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
HttpRequest model
|
|
667
|
+
"""
|
|
668
|
+
parsed = urlparse(url)
|
|
669
|
+
|
|
670
|
+
query_params: Dict[str, str] = {}
|
|
671
|
+
if parsed.query:
|
|
672
|
+
for key, values in parse_qs(parsed.query).items():
|
|
673
|
+
query_params[key] = values[0] if values else ""
|
|
674
|
+
|
|
675
|
+
# Normalize headers - may contain bytes values from botocore
|
|
676
|
+
headers_dict: Dict[str, str] = {}
|
|
677
|
+
if headers:
|
|
678
|
+
for k, v in (headers.items() if hasattr(headers, "items") else headers):
|
|
679
|
+
key = k.decode("utf-8") if isinstance(k, bytes) else str(k)
|
|
680
|
+
val = v.decode("utf-8") if isinstance(v, bytes) else str(v)
|
|
681
|
+
headers_dict[key] = val
|
|
682
|
+
|
|
683
|
+
content_type = headers_dict.get("content-type", headers_dict.get("Content-Type", ""))
|
|
684
|
+
|
|
685
|
+
body_value, body_encoding, _ = _serialize_body(body, content_type)
|
|
686
|
+
|
|
687
|
+
body_str = None
|
|
688
|
+
if body_value is not None:
|
|
689
|
+
if body_encoding == "base64":
|
|
690
|
+
body_str = body_value
|
|
691
|
+
elif isinstance(body_value, str):
|
|
692
|
+
body_str = body_value
|
|
693
|
+
else:
|
|
694
|
+
body_str = json.dumps(body_value, ensure_ascii=False)
|
|
695
|
+
|
|
696
|
+
return HttpRequest(
|
|
697
|
+
method=method,
|
|
698
|
+
url=url,
|
|
699
|
+
headers=headers_dict,
|
|
700
|
+
queryParameters=query_params,
|
|
701
|
+
body=body_str,
|
|
702
|
+
bodyEncoding=body_encoding,
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def create_response_from_urllib3(
|
|
707
|
+
response: Any, # urllib3.HTTPResponse
|
|
708
|
+
) -> HttpResponse:
|
|
709
|
+
"""
|
|
710
|
+
Create HttpResponse from a urllib3 HTTPResponse object.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
response: urllib3.HTTPResponse object
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
HttpResponse model
|
|
717
|
+
"""
|
|
718
|
+
headers = dict(response.headers) if response.headers else {}
|
|
719
|
+
content_type = headers.get("content-type", headers.get("Content-Type", ""))
|
|
720
|
+
|
|
721
|
+
body_bytes = response.data
|
|
722
|
+
body, body_encoding, is_binary = _serialize_body(body_bytes, content_type)
|
|
723
|
+
|
|
724
|
+
if is_binary:
|
|
725
|
+
resp = HttpResponse(
|
|
726
|
+
status=response.status,
|
|
727
|
+
headers=headers,
|
|
728
|
+
body=None,
|
|
729
|
+
)
|
|
730
|
+
resp._body_bytes = body if isinstance(body, bytes) else body_bytes
|
|
731
|
+
return resp
|
|
732
|
+
|
|
733
|
+
body_str = ""
|
|
734
|
+
if body is not None:
|
|
735
|
+
if isinstance(body, str):
|
|
736
|
+
body_str = body
|
|
737
|
+
else:
|
|
738
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
739
|
+
|
|
740
|
+
return HttpResponse(
|
|
741
|
+
status=response.status,
|
|
742
|
+
headers=headers,
|
|
743
|
+
body=body_str,
|
|
744
|
+
bodyEncoding=body_encoding,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
# ============================================================================
|
|
749
|
+
# http.client functions
|
|
750
|
+
# ============================================================================
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def create_request_from_http_client(
|
|
754
|
+
method: str,
|
|
755
|
+
url: str,
|
|
756
|
+
body: Any = None,
|
|
757
|
+
headers: Dict[str, str] = None,
|
|
758
|
+
) -> HttpRequest:
|
|
759
|
+
"""
|
|
760
|
+
Create HttpRequest from http.client request parameters.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
method: HTTP method
|
|
764
|
+
url: Full URL string
|
|
765
|
+
body: Request body
|
|
766
|
+
headers: Request headers dict
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
HttpRequest model
|
|
770
|
+
"""
|
|
771
|
+
parsed = urlparse(url)
|
|
772
|
+
|
|
773
|
+
query_params: Dict[str, str] = {}
|
|
774
|
+
if parsed.query:
|
|
775
|
+
for key, values in parse_qs(parsed.query).items():
|
|
776
|
+
query_params[key] = values[0] if values else ""
|
|
777
|
+
|
|
778
|
+
headers_dict = headers or {}
|
|
779
|
+
content_type = headers_dict.get("content-type", headers_dict.get("Content-Type", ""))
|
|
780
|
+
|
|
781
|
+
body_value, body_encoding, _ = _serialize_body(body, content_type)
|
|
782
|
+
|
|
783
|
+
body_str = None
|
|
784
|
+
if body_value is not None:
|
|
785
|
+
if body_encoding == "base64":
|
|
786
|
+
body_str = body_value
|
|
787
|
+
elif isinstance(body_value, str):
|
|
788
|
+
body_str = body_value
|
|
789
|
+
else:
|
|
790
|
+
body_str = json.dumps(body_value, ensure_ascii=False)
|
|
791
|
+
|
|
792
|
+
return HttpRequest(
|
|
793
|
+
method=method,
|
|
794
|
+
url=url,
|
|
795
|
+
headers=headers_dict,
|
|
796
|
+
queryParameters=query_params,
|
|
797
|
+
body=body_str,
|
|
798
|
+
bodyEncoding=body_encoding,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def create_response_from_http_client(
|
|
803
|
+
response: Any, # http.client.HTTPResponse
|
|
804
|
+
) -> HttpResponse:
|
|
805
|
+
"""
|
|
806
|
+
Create HttpResponse from an http.client HTTPResponse object.
|
|
807
|
+
|
|
808
|
+
Args:
|
|
809
|
+
response: http.client.HTTPResponse object
|
|
810
|
+
|
|
811
|
+
Returns:
|
|
812
|
+
HttpResponse model
|
|
813
|
+
"""
|
|
814
|
+
headers = dict(response.getheaders()) if hasattr(response, "getheaders") else {}
|
|
815
|
+
content_type = headers.get("content-type", headers.get("Content-Type", ""))
|
|
816
|
+
|
|
817
|
+
body_bytes = response.read()
|
|
818
|
+
body, body_encoding, is_binary = _serialize_body(body_bytes, content_type)
|
|
819
|
+
|
|
820
|
+
if is_binary:
|
|
821
|
+
resp = HttpResponse(
|
|
822
|
+
status=response.status,
|
|
823
|
+
headers=headers,
|
|
824
|
+
body=None,
|
|
825
|
+
)
|
|
826
|
+
resp._body_bytes = body if isinstance(body, bytes) else body_bytes
|
|
827
|
+
return resp
|
|
828
|
+
|
|
829
|
+
body_str = ""
|
|
830
|
+
if body is not None:
|
|
831
|
+
if isinstance(body, str):
|
|
832
|
+
body_str = body
|
|
833
|
+
else:
|
|
834
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
835
|
+
|
|
836
|
+
return HttpResponse(
|
|
837
|
+
status=response.status,
|
|
838
|
+
headers=headers,
|
|
839
|
+
body=body_str,
|
|
840
|
+
bodyEncoding=body_encoding,
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
# ============================================================================
|
|
845
|
+
# aiohttp functions
|
|
846
|
+
# ============================================================================
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def create_request_from_aiohttp(
|
|
850
|
+
method: str,
|
|
851
|
+
url: str,
|
|
852
|
+
data: Any = None,
|
|
853
|
+
headers: Any = None,
|
|
854
|
+
) -> HttpRequest:
|
|
855
|
+
"""
|
|
856
|
+
Create HttpRequest from aiohttp request parameters.
|
|
857
|
+
|
|
858
|
+
Args:
|
|
859
|
+
method: HTTP method
|
|
860
|
+
url: URL string
|
|
861
|
+
data: Request body data
|
|
862
|
+
headers: Request headers
|
|
863
|
+
|
|
864
|
+
Returns:
|
|
865
|
+
HttpRequest model
|
|
866
|
+
"""
|
|
867
|
+
parsed = urlparse(url)
|
|
868
|
+
|
|
869
|
+
query_params: Dict[str, str] = {}
|
|
870
|
+
if parsed.query:
|
|
871
|
+
for key, values in parse_qs(parsed.query).items():
|
|
872
|
+
query_params[key] = values[0] if values else ""
|
|
873
|
+
|
|
874
|
+
headers_dict = dict(headers) if headers else {}
|
|
875
|
+
content_type = headers_dict.get("content-type", headers_dict.get("Content-Type", ""))
|
|
876
|
+
|
|
877
|
+
body_value, body_encoding, _ = _serialize_body(data, content_type)
|
|
878
|
+
|
|
879
|
+
body_str = None
|
|
880
|
+
if body_value is not None:
|
|
881
|
+
if body_encoding == "base64":
|
|
882
|
+
body_str = body_value
|
|
883
|
+
elif isinstance(body_value, str):
|
|
884
|
+
body_str = body_value
|
|
885
|
+
else:
|
|
886
|
+
body_str = json.dumps(body_value, ensure_ascii=False)
|
|
887
|
+
|
|
888
|
+
return HttpRequest(
|
|
889
|
+
method=method.upper(),
|
|
890
|
+
url=url,
|
|
891
|
+
headers=headers_dict,
|
|
892
|
+
queryParameters=query_params,
|
|
893
|
+
body=body_str,
|
|
894
|
+
bodyEncoding=body_encoding,
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def create_response_from_aiohttp(
|
|
899
|
+
response: Any, # aiohttp.ClientResponse
|
|
900
|
+
body_bytes: bytes,
|
|
901
|
+
) -> HttpResponse:
|
|
902
|
+
"""
|
|
903
|
+
Create HttpResponse from an aiohttp ClientResponse object.
|
|
904
|
+
|
|
905
|
+
Args:
|
|
906
|
+
response: aiohttp.ClientResponse object
|
|
907
|
+
body_bytes: Already-read response body bytes
|
|
908
|
+
|
|
909
|
+
Returns:
|
|
910
|
+
HttpResponse model
|
|
911
|
+
"""
|
|
912
|
+
headers = dict(response.headers) if response.headers else {}
|
|
913
|
+
content_type = headers.get("content-type", headers.get("Content-Type", ""))
|
|
914
|
+
|
|
915
|
+
body, body_encoding, is_binary = _serialize_body(body_bytes, content_type)
|
|
916
|
+
|
|
917
|
+
if is_binary:
|
|
918
|
+
resp = HttpResponse(
|
|
919
|
+
status=response.status,
|
|
920
|
+
headers=headers,
|
|
921
|
+
body=None,
|
|
922
|
+
)
|
|
923
|
+
resp._body_bytes = body if isinstance(body, bytes) else body_bytes
|
|
924
|
+
return resp
|
|
925
|
+
|
|
926
|
+
body_str = ""
|
|
927
|
+
if body is not None:
|
|
928
|
+
if isinstance(body, str):
|
|
929
|
+
body_str = body
|
|
930
|
+
else:
|
|
931
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
932
|
+
|
|
933
|
+
return HttpResponse(
|
|
934
|
+
status=response.status,
|
|
935
|
+
headers=headers,
|
|
936
|
+
body=body_str,
|
|
937
|
+
bodyEncoding=body_encoding,
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
# ============================================================================
|
|
942
|
+
# httplib2 functions
|
|
943
|
+
# ============================================================================
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def create_request_from_httplib2(
|
|
947
|
+
uri: str,
|
|
948
|
+
method: str = "GET",
|
|
949
|
+
body: Any = None,
|
|
950
|
+
headers: Any = None,
|
|
951
|
+
) -> HttpRequest:
|
|
952
|
+
"""
|
|
953
|
+
Create HttpRequest from httplib2 request parameters.
|
|
954
|
+
|
|
955
|
+
Args:
|
|
956
|
+
uri: Request URI
|
|
957
|
+
method: HTTP method
|
|
958
|
+
body: Request body
|
|
959
|
+
headers: Request headers
|
|
960
|
+
|
|
961
|
+
Returns:
|
|
962
|
+
HttpRequest model
|
|
963
|
+
"""
|
|
964
|
+
parsed = urlparse(uri)
|
|
965
|
+
|
|
966
|
+
query_params: Dict[str, str] = {}
|
|
967
|
+
if parsed.query:
|
|
968
|
+
for key, values in parse_qs(parsed.query).items():
|
|
969
|
+
query_params[key] = values[0] if values else ""
|
|
970
|
+
|
|
971
|
+
headers_dict = dict(headers) if headers else {}
|
|
972
|
+
content_type = headers_dict.get("content-type", headers_dict.get("Content-Type", ""))
|
|
973
|
+
|
|
974
|
+
body_value, body_encoding, _ = _serialize_body(body, content_type)
|
|
975
|
+
|
|
976
|
+
body_str = None
|
|
977
|
+
if body_value is not None:
|
|
978
|
+
if body_encoding == "base64":
|
|
979
|
+
body_str = body_value
|
|
980
|
+
elif isinstance(body_value, str):
|
|
981
|
+
body_str = body_value
|
|
982
|
+
else:
|
|
983
|
+
body_str = json.dumps(body_value, ensure_ascii=False)
|
|
984
|
+
|
|
985
|
+
return HttpRequest(
|
|
986
|
+
method=method.upper(),
|
|
987
|
+
url=uri,
|
|
988
|
+
headers=headers_dict,
|
|
989
|
+
queryParameters=query_params,
|
|
990
|
+
body=body_str,
|
|
991
|
+
bodyEncoding=body_encoding,
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def create_response_from_httplib2(
|
|
996
|
+
response: Any, # httplib2.Response
|
|
997
|
+
content: bytes,
|
|
998
|
+
) -> HttpResponse:
|
|
999
|
+
"""
|
|
1000
|
+
Create HttpResponse from an httplib2 Response object.
|
|
1001
|
+
|
|
1002
|
+
Args:
|
|
1003
|
+
response: httplib2.Response object
|
|
1004
|
+
content: Response body bytes
|
|
1005
|
+
|
|
1006
|
+
Returns:
|
|
1007
|
+
HttpResponse model
|
|
1008
|
+
"""
|
|
1009
|
+
headers = dict(response) if response else {}
|
|
1010
|
+
content_type = headers.get("content-type", headers.get("Content-Type", ""))
|
|
1011
|
+
|
|
1012
|
+
body, body_encoding, is_binary = _serialize_body(content, content_type)
|
|
1013
|
+
|
|
1014
|
+
if is_binary:
|
|
1015
|
+
resp = HttpResponse(
|
|
1016
|
+
status=response.status,
|
|
1017
|
+
headers=headers,
|
|
1018
|
+
body=None,
|
|
1019
|
+
)
|
|
1020
|
+
resp._body_bytes = body if isinstance(body, bytes) else content
|
|
1021
|
+
return resp
|
|
1022
|
+
|
|
1023
|
+
body_str = ""
|
|
1024
|
+
if body is not None:
|
|
1025
|
+
if isinstance(body, str):
|
|
1026
|
+
body_str = body
|
|
1027
|
+
else:
|
|
1028
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
1029
|
+
|
|
1030
|
+
return HttpResponse(
|
|
1031
|
+
status=response.status,
|
|
1032
|
+
headers=headers,
|
|
1033
|
+
body=body_str,
|
|
1034
|
+
bodyEncoding=body_encoding,
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
# ============================================================================
|
|
1039
|
+
# tornado functions
|
|
1040
|
+
# ============================================================================
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def create_request_from_tornado(
|
|
1044
|
+
request: Any, # tornado.httpclient.HTTPRequest
|
|
1045
|
+
) -> HttpRequest:
|
|
1046
|
+
"""
|
|
1047
|
+
Create HttpRequest from a tornado HTTPRequest object.
|
|
1048
|
+
|
|
1049
|
+
Args:
|
|
1050
|
+
request: tornado.httpclient.HTTPRequest object
|
|
1051
|
+
|
|
1052
|
+
Returns:
|
|
1053
|
+
HttpRequest model
|
|
1054
|
+
"""
|
|
1055
|
+
url = request.url
|
|
1056
|
+
parsed = urlparse(url)
|
|
1057
|
+
|
|
1058
|
+
query_params: Dict[str, str] = {}
|
|
1059
|
+
if parsed.query:
|
|
1060
|
+
for key, values in parse_qs(parsed.query).items():
|
|
1061
|
+
query_params[key] = values[0] if values else ""
|
|
1062
|
+
|
|
1063
|
+
headers_dict = dict(request.headers) if request.headers else {}
|
|
1064
|
+
content_type = headers_dict.get("content-type", headers_dict.get("Content-Type", ""))
|
|
1065
|
+
|
|
1066
|
+
body = request.body
|
|
1067
|
+
body_value, body_encoding, _ = _serialize_body(body, content_type)
|
|
1068
|
+
|
|
1069
|
+
body_str = None
|
|
1070
|
+
if body_value is not None:
|
|
1071
|
+
if body_encoding == "base64":
|
|
1072
|
+
body_str = body_value
|
|
1073
|
+
elif isinstance(body_value, str):
|
|
1074
|
+
body_str = body_value
|
|
1075
|
+
else:
|
|
1076
|
+
body_str = json.dumps(body_value, ensure_ascii=False)
|
|
1077
|
+
|
|
1078
|
+
return HttpRequest(
|
|
1079
|
+
method=request.method or "GET",
|
|
1080
|
+
url=url,
|
|
1081
|
+
headers=headers_dict,
|
|
1082
|
+
queryParameters=query_params,
|
|
1083
|
+
body=body_str,
|
|
1084
|
+
bodyEncoding=body_encoding,
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def create_response_from_tornado(
|
|
1089
|
+
response: Any, # tornado.httpclient.HTTPResponse
|
|
1090
|
+
) -> HttpResponse:
|
|
1091
|
+
"""
|
|
1092
|
+
Create HttpResponse from a tornado HTTPResponse object.
|
|
1093
|
+
|
|
1094
|
+
Args:
|
|
1095
|
+
response: tornado.httpclient.HTTPResponse object
|
|
1096
|
+
|
|
1097
|
+
Returns:
|
|
1098
|
+
HttpResponse model
|
|
1099
|
+
"""
|
|
1100
|
+
headers = dict(response.headers) if response.headers else {}
|
|
1101
|
+
content_type = headers.get("content-type", headers.get("Content-Type", ""))
|
|
1102
|
+
|
|
1103
|
+
body_bytes = response.body or b""
|
|
1104
|
+
body, body_encoding, is_binary = _serialize_body(body_bytes, content_type)
|
|
1105
|
+
|
|
1106
|
+
if is_binary:
|
|
1107
|
+
resp = HttpResponse(
|
|
1108
|
+
status=response.code,
|
|
1109
|
+
headers=headers,
|
|
1110
|
+
body=None,
|
|
1111
|
+
)
|
|
1112
|
+
resp._body_bytes = body if isinstance(body, bytes) else body_bytes
|
|
1113
|
+
return resp
|
|
1114
|
+
|
|
1115
|
+
body_str = ""
|
|
1116
|
+
if body is not None:
|
|
1117
|
+
if isinstance(body, str):
|
|
1118
|
+
body_str = body
|
|
1119
|
+
else:
|
|
1120
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
1121
|
+
|
|
1122
|
+
return HttpResponse(
|
|
1123
|
+
status=response.code,
|
|
1124
|
+
headers=headers,
|
|
1125
|
+
body=body_str,
|
|
1126
|
+
bodyEncoding=body_encoding,
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
# ============================================================================
|
|
1131
|
+
# botocore functions
|
|
1132
|
+
# ============================================================================
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def create_request_from_botocore(
|
|
1136
|
+
request: Any, # botocore.awsrequest.AWSRequest
|
|
1137
|
+
) -> HttpRequest:
|
|
1138
|
+
"""
|
|
1139
|
+
Create HttpRequest from a botocore AWSRequest object.
|
|
1140
|
+
|
|
1141
|
+
Args:
|
|
1142
|
+
request: botocore.awsrequest.AWSRequest object
|
|
1143
|
+
|
|
1144
|
+
Returns:
|
|
1145
|
+
HttpRequest model
|
|
1146
|
+
"""
|
|
1147
|
+
url = request.url
|
|
1148
|
+
parsed = urlparse(url)
|
|
1149
|
+
|
|
1150
|
+
query_params: Dict[str, str] = {}
|
|
1151
|
+
if parsed.query:
|
|
1152
|
+
for key, values in parse_qs(parsed.query).items():
|
|
1153
|
+
query_params[key] = values[0] if values else ""
|
|
1154
|
+
|
|
1155
|
+
# Normalize headers - botocore can have bytes values
|
|
1156
|
+
headers_dict: Dict[str, str] = {}
|
|
1157
|
+
if request.headers:
|
|
1158
|
+
for k, v in request.headers.items():
|
|
1159
|
+
key = k.decode("utf-8") if isinstance(k, bytes) else k
|
|
1160
|
+
val = v.decode("utf-8") if isinstance(v, bytes) else v
|
|
1161
|
+
headers_dict[key] = val
|
|
1162
|
+
|
|
1163
|
+
content_type = headers_dict.get("content-type", headers_dict.get("Content-Type", ""))
|
|
1164
|
+
|
|
1165
|
+
body = request.body
|
|
1166
|
+
body_value, body_encoding, _ = _serialize_body(body, content_type)
|
|
1167
|
+
|
|
1168
|
+
body_str = None
|
|
1169
|
+
if body_value is not None:
|
|
1170
|
+
if body_encoding == "base64":
|
|
1171
|
+
body_str = body_value
|
|
1172
|
+
elif isinstance(body_value, str):
|
|
1173
|
+
body_str = body_value
|
|
1174
|
+
else:
|
|
1175
|
+
body_str = json.dumps(body_value, ensure_ascii=False)
|
|
1176
|
+
|
|
1177
|
+
return HttpRequest(
|
|
1178
|
+
method=request.method or "GET",
|
|
1179
|
+
url=url,
|
|
1180
|
+
headers=headers_dict,
|
|
1181
|
+
queryParameters=query_params,
|
|
1182
|
+
body=body_str,
|
|
1183
|
+
bodyEncoding=body_encoding,
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def create_response_from_botocore(
|
|
1188
|
+
response: Any, # botocore.awsrequest.AWSResponse
|
|
1189
|
+
) -> HttpResponse:
|
|
1190
|
+
"""
|
|
1191
|
+
Create HttpResponse from a botocore AWSResponse object.
|
|
1192
|
+
|
|
1193
|
+
Args:
|
|
1194
|
+
response: botocore.awsrequest.AWSResponse object
|
|
1195
|
+
|
|
1196
|
+
Returns:
|
|
1197
|
+
HttpResponse model
|
|
1198
|
+
"""
|
|
1199
|
+
# AWSResponse has headers as HTTPHeaderDict
|
|
1200
|
+
headers = dict(response.headers) if response.headers else {}
|
|
1201
|
+
content_type = headers.get("content-type", headers.get("Content-Type", ""))
|
|
1202
|
+
|
|
1203
|
+
# AWSResponse has content property for body bytes
|
|
1204
|
+
body_bytes = response.content
|
|
1205
|
+
body, body_encoding, is_binary = _serialize_body(body_bytes, content_type)
|
|
1206
|
+
|
|
1207
|
+
# AWSResponse uses status_code, not status
|
|
1208
|
+
status = response.status_code
|
|
1209
|
+
|
|
1210
|
+
if is_binary:
|
|
1211
|
+
resp = HttpResponse(
|
|
1212
|
+
status=status,
|
|
1213
|
+
headers=headers,
|
|
1214
|
+
body=None,
|
|
1215
|
+
)
|
|
1216
|
+
resp._body_bytes = body if isinstance(body, bytes) else body_bytes
|
|
1217
|
+
return resp
|
|
1218
|
+
|
|
1219
|
+
body_str = ""
|
|
1220
|
+
if body is not None:
|
|
1221
|
+
if isinstance(body, str):
|
|
1222
|
+
body_str = body
|
|
1223
|
+
else:
|
|
1224
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
1225
|
+
|
|
1226
|
+
return HttpResponse(
|
|
1227
|
+
status=status,
|
|
1228
|
+
headers=headers,
|
|
1229
|
+
body=body_str,
|
|
1230
|
+
bodyEncoding=body_encoding,
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
# ============================================================================
|
|
1235
|
+
# httpcore functions
|
|
1236
|
+
# ============================================================================
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def create_request_from_httpcore(
|
|
1240
|
+
method: str,
|
|
1241
|
+
url: str,
|
|
1242
|
+
content: Any = None,
|
|
1243
|
+
headers: Any = None,
|
|
1244
|
+
) -> HttpRequest:
|
|
1245
|
+
"""
|
|
1246
|
+
Create HttpRequest from httpcore request parameters.
|
|
1247
|
+
|
|
1248
|
+
Args:
|
|
1249
|
+
method: HTTP method
|
|
1250
|
+
url: URL string
|
|
1251
|
+
content: Request body content
|
|
1252
|
+
headers: Request headers (list of tuples or bytes pairs)
|
|
1253
|
+
|
|
1254
|
+
Returns:
|
|
1255
|
+
HttpRequest model
|
|
1256
|
+
"""
|
|
1257
|
+
parsed = urlparse(url)
|
|
1258
|
+
|
|
1259
|
+
query_params: Dict[str, str] = {}
|
|
1260
|
+
if parsed.query:
|
|
1261
|
+
for key, values in parse_qs(parsed.query).items():
|
|
1262
|
+
query_params[key] = values[0] if values else ""
|
|
1263
|
+
|
|
1264
|
+
# Convert headers from list of tuples to dict
|
|
1265
|
+
headers_dict: Dict[str, str] = {}
|
|
1266
|
+
if headers:
|
|
1267
|
+
for k, v in headers:
|
|
1268
|
+
key = k.decode() if isinstance(k, bytes) else k
|
|
1269
|
+
val = v.decode() if isinstance(v, bytes) else v
|
|
1270
|
+
headers_dict[key] = val
|
|
1271
|
+
|
|
1272
|
+
content_type = headers_dict.get("content-type", headers_dict.get("Content-Type", ""))
|
|
1273
|
+
|
|
1274
|
+
# Handle content
|
|
1275
|
+
if isinstance(content, bytes):
|
|
1276
|
+
body_data = content
|
|
1277
|
+
elif hasattr(content, "read"):
|
|
1278
|
+
body_data = content.read()
|
|
1279
|
+
else:
|
|
1280
|
+
body_data = content
|
|
1281
|
+
|
|
1282
|
+
body_value, body_encoding, _ = _serialize_body(body_data, content_type)
|
|
1283
|
+
|
|
1284
|
+
body_str = None
|
|
1285
|
+
if body_value is not None:
|
|
1286
|
+
if body_encoding == "base64":
|
|
1287
|
+
body_str = body_value
|
|
1288
|
+
elif isinstance(body_value, str):
|
|
1289
|
+
body_str = body_value
|
|
1290
|
+
else:
|
|
1291
|
+
body_str = json.dumps(body_value, ensure_ascii=False)
|
|
1292
|
+
|
|
1293
|
+
return HttpRequest(
|
|
1294
|
+
method=method.upper(),
|
|
1295
|
+
url=url,
|
|
1296
|
+
headers=headers_dict,
|
|
1297
|
+
queryParameters=query_params,
|
|
1298
|
+
body=body_str,
|
|
1299
|
+
bodyEncoding=body_encoding,
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def create_response_from_httpcore(
|
|
1304
|
+
response: Any, # httpcore.Response
|
|
1305
|
+
) -> HttpResponse:
|
|
1306
|
+
"""
|
|
1307
|
+
Create HttpResponse from an httpcore Response object.
|
|
1308
|
+
|
|
1309
|
+
Args:
|
|
1310
|
+
response: httpcore.Response object
|
|
1311
|
+
|
|
1312
|
+
Returns:
|
|
1313
|
+
HttpResponse model
|
|
1314
|
+
"""
|
|
1315
|
+
# Convert headers from list of tuples to dict
|
|
1316
|
+
headers_dict: Dict[str, str] = {}
|
|
1317
|
+
if response.headers:
|
|
1318
|
+
for k, v in response.headers:
|
|
1319
|
+
key = k.decode() if isinstance(k, bytes) else k
|
|
1320
|
+
val = v.decode() if isinstance(v, bytes) else v
|
|
1321
|
+
headers_dict[key] = val
|
|
1322
|
+
|
|
1323
|
+
content_type = headers_dict.get("content-type", headers_dict.get("Content-Type", ""))
|
|
1324
|
+
|
|
1325
|
+
# Get body content
|
|
1326
|
+
body_bytes = response.content
|
|
1327
|
+
body, body_encoding, is_binary = _serialize_body(body_bytes, content_type)
|
|
1328
|
+
|
|
1329
|
+
if is_binary:
|
|
1330
|
+
resp = HttpResponse(
|
|
1331
|
+
status=response.status,
|
|
1332
|
+
headers=headers_dict,
|
|
1333
|
+
body=None,
|
|
1334
|
+
)
|
|
1335
|
+
resp._body_bytes = body if isinstance(body, bytes) else body_bytes
|
|
1336
|
+
return resp
|
|
1337
|
+
|
|
1338
|
+
body_str = ""
|
|
1339
|
+
if body is not None:
|
|
1340
|
+
if isinstance(body, str):
|
|
1341
|
+
body_str = body
|
|
1342
|
+
else:
|
|
1343
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
1344
|
+
|
|
1345
|
+
return HttpResponse(
|
|
1346
|
+
status=response.status,
|
|
1347
|
+
headers=headers_dict,
|
|
1348
|
+
body=body_str,
|
|
1349
|
+
bodyEncoding=body_encoding,
|
|
1350
|
+
)
|