swagtrace 0.0.5__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.
swagtrace/__init__.py ADDED
File without changes
swagtrace/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from swagtrace.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
swagtrace/cli.py ADDED
@@ -0,0 +1,41 @@
1
+ import argparse
2
+
3
+ from swagtrace.core import load_commands
4
+
5
+
6
+ def main():
7
+
8
+ common_parser = argparse.ArgumentParser(add_help=False)
9
+ common_parser.add_argument(
10
+ "--config",
11
+ default="swagtrace.toml",
12
+ help="Path to config file",
13
+ )
14
+
15
+ parser = argparse.ArgumentParser(
16
+ prog="swagtrace",
17
+ description="SwagTrace - API Test Recorder & Runner",
18
+ parents=[common_parser],
19
+ )
20
+
21
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
22
+
23
+ commands = load_commands(subparsers=subparsers)
24
+
25
+ args = parser.parse_args()
26
+
27
+ if hasattr(args, "func"):
28
+ command = args.command
29
+ kwargs = {
30
+ arg[0]: arg[1]
31
+ for arg in args._get_kwargs()
32
+ if arg[0] != "command" and arg[0] != "func"
33
+ }
34
+
35
+ commands.run_command_dependencies(command=command, **kwargs)
36
+
37
+ kwargs.pop("config")
38
+
39
+ return args.func(**kwargs)
40
+ else:
41
+ parser.print_help()
swagtrace/consts.py ADDED
@@ -0,0 +1,69 @@
1
+ DEFAULT_YAML_FILE = "swagtrace.yaml"
2
+
3
+ DEFAULT_TEST_MODULE_FOLDER = "swagtrace_tests"
4
+
5
+ PREPARE_AND_FINAL_FORMAT_FILE = """from subprocess import CompletedProcess
6
+ # 1. Configuration & Variables
7
+ # Define key-value pairs to be injected into the YAML template.
8
+ # You can set static values here or dynamically update this dictionary inside prepare().
9
+ # NOTE: Variable names (keys) are CASE-SENSITIVE (e.g., 'DB_PORT' != 'db_port').
10
+ VARIABLES = {}
11
+
12
+ # 2. Main Execution Phase
13
+ # This function runs as the FINAL step, right after the 'execute' command in the YAML file has finished.
14
+ # Place your core logic or post-processing tasks here.
15
+ def main(cp: CompletedProcess):
16
+ print(cp.stdout)
17
+
18
+ """
19
+
20
+
21
+ TEST_CASE_FORMAT_FILE = """from httpx import Response
22
+
23
+ # -----------------------------------------------------------------------------
24
+ # 1. CONFIGURATION & DYNAMIC VARIABLES
25
+ # -----------------------------------------------------------------------------
26
+ # Key-value pairs injected into the HTTP Request template (e.g., YAML/JSON).
27
+ # You can define static defaults here or dynamically populate/override them
28
+ # inside the prepare() function.
29
+ # NOTE: Variable names (keys) are CASE-SENSITIVE (e.g., 'TOKEN' != 'token').
30
+ VARIABLES = %(variables)s
31
+
32
+
33
+ # -----------------------------------------------------------------------------
34
+ # 2. PREPARATION PHASE (PHASE 1 - Runs FIRST)
35
+ # -----------------------------------------------------------------------------
36
+ # Executed BEFORE generating the request template and making the HTTP call.
37
+ # Use this to:
38
+ # - Set up prerequisites (e.g., seed database, generate mock payload).
39
+ # - Fetch authorization tokens or dynamic parameters.
40
+ # - Inject values directly into the 'VARIABLES' dictionary.
41
+ def prepare():
42
+ pass
43
+
44
+
45
+ # -----------------------------------------------------------------------------
46
+ # 3. ASSERTION & INSPECTION PHASE (PHASE 2 - Runs AFTER HTTP Request)
47
+ # -----------------------------------------------------------------------------
48
+ # Executed IMMEDIATELY after the API call finishes.
49
+ # Receives the live 'requests.Response' object from the target server.
50
+ # Use this to:
51
+ # - Write custom asserts (e.g., assert response.status_code == 200).
52
+ # - Parse response JSON/Headers and validate domain logic.
53
+ # - Store state/IDs created by this request for downstream use.
54
+ def main(response: Response):
55
+ pass
56
+
57
+
58
+ # -----------------------------------------------------------------------------
59
+ # 4. CLEANUP & TEARDOWN PHASE (PHASE 3 - Runs LAST)
60
+ # -----------------------------------------------------------------------------
61
+ # Executed as the final step after main() completes.
62
+ # Use this optional step to clean up any side-effects or temporary data:
63
+ # - Delete objects created in database/API during the test execution.
64
+ # - Invalidate session tokens or flush temporary test state.
65
+ def finalize():
66
+ pass
67
+
68
+ """
69
+
@@ -0,0 +1 @@
1
+ from .commands import load_commands
@@ -0,0 +1,25 @@
1
+ from argparse import _SubParsersAction
2
+ from collections.abc import Callable
3
+
4
+ from swagtrace.schemas.commands import Command
5
+ from swagtrace.shared import load_config
6
+
7
+ from .initializer import set_initializer_command
8
+ from .recorder import set_recorder_command
9
+ from .tester import set_tester_command
10
+
11
+
12
+ def load_commands(subparsers: _SubParsersAction) -> Command:
13
+
14
+ commands: dict[str, list[Callable]] = {}
15
+
16
+ init_command = set_initializer_command(subparsers=subparsers)
17
+ commands[init_command] = []
18
+
19
+ recorder_command = set_recorder_command(subparsers=subparsers)
20
+ commands[recorder_command] = [load_config]
21
+
22
+ tester_command = set_tester_command(subparsers=subparsers)
23
+ commands[tester_command] = [load_config]
24
+
25
+ return Command(commands=commands)
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ from argparse import _SubParsersAction
6
+ from importlib.resources import files
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+ import yaml
12
+
13
+ from swagtrace.consts import (
14
+ DEFAULT_TEST_MODULE_FOLDER,
15
+ DEFAULT_YAML_FILE,
16
+ PREPARE_AND_FINAL_FORMAT_FILE,
17
+ )
18
+ from swagtrace.schemas.yaml_schema import (
19
+ ElementInfo,
20
+ SwagTaceTestFormat,
21
+ prepareAndFinal,
22
+ )
23
+
24
+
25
+ def fetch_openapi(
26
+ url: str | None, file: str | None, timeout: float = 10.0
27
+ ) -> dict[str, Any]:
28
+ if file:
29
+ file_json_format_content = {}
30
+
31
+ with open(file, "r") as file_object:
32
+ if file.endswith((".yaml", ".yml")):
33
+ file_json_format_content = yaml.safe_load(file_object.read())
34
+ else:
35
+ file_json_format_content = json.load(file_object)
36
+ return file_json_format_content
37
+
38
+ else:
39
+ response = httpx.get(url, timeout=timeout)
40
+ response.raise_for_status()
41
+
42
+ content_type = response.headers.get("content-type", "")
43
+ if "yaml" in content_type or url.endswith((".yaml", ".yml")):
44
+ return yaml.safe_load(response.text)
45
+ return response.json()
46
+
47
+
48
+ def extract_endpoints(spec: dict[str, Any]) -> SwagTaceTestFormat:
49
+
50
+ prepare = prepareAndFinal(execute="echo Starting tests ...")
51
+ final = prepareAndFinal(execute="echo test complete")
52
+ info = spec.get("info", {})
53
+ openapi = spec.get("openapi", "")
54
+
55
+ tags_map: dict[str, list[dict[str, Any]]] = {}
56
+
57
+ paths = spec.get("paths", {})
58
+
59
+ for path, path_item in paths.items():
60
+ if not isinstance(path_item, dict):
61
+ continue
62
+
63
+ for method, operation in path_item.items():
64
+ if method.lower() not in {
65
+ "get",
66
+ "post",
67
+ "put",
68
+ "patch",
69
+ "delete",
70
+ "head",
71
+ "options",
72
+ }:
73
+ continue
74
+ if not isinstance(operation, dict):
75
+ continue
76
+
77
+ tags = operation.get("tags", [])
78
+ tag = tags[0] if tags else "Default"
79
+
80
+ endpoint_info = ElementInfo(
81
+ method=method.upper(),
82
+ path=path,
83
+ operation_id=operation.get("operationId"),
84
+ summary=operation.get("summary"),
85
+ description=operation.get("description"),
86
+ cases=[],
87
+ )
88
+
89
+ if tag not in tags_map:
90
+ tags_map[tag] = []
91
+
92
+ tags_map[tag].append(endpoint_info)
93
+
94
+ return SwagTaceTestFormat(
95
+ openapi=openapi, info=info, prepare=prepare, tags=tags_map, final=final
96
+ )
97
+
98
+
99
+ def save_endpoints_yaml(endpoints: SwagTaceTestFormat, output_path: str) -> None:
100
+ file_name = DEFAULT_YAML_FILE
101
+
102
+ output_path = Path(output_path) / Path(file_name)
103
+
104
+ endpoints = endpoints.model_dump()
105
+ with output_path.open("w", encoding="utf-8") as f:
106
+ yaml.dump(
107
+ endpoints, f, allow_unicode=True, sort_keys=False, default_flow_style=False
108
+ )
109
+
110
+
111
+ def create_test_module(output_path: str):
112
+
113
+ output_path: Path = Path(output_path)
114
+ module_path: Path = output_path / Path(DEFAULT_TEST_MODULE_FOLDER)
115
+ __init__file = module_path / Path("__init__.py")
116
+ prepare_file = module_path / Path("prepare.py")
117
+ final_file = module_path / Path("final.py")
118
+
119
+ module_path.mkdir()
120
+
121
+ __init__file.touch()
122
+ prepare_file.write_text(PREPARE_AND_FINAL_FORMAT_FILE)
123
+ final_file.write_text(PREPARE_AND_FINAL_FORMAT_FILE)
124
+
125
+
126
+ def init_config_file(output_path: str):
127
+ template_path = Path(files("swagtrace.templates").joinpath("config.toml"))
128
+ target_path = output_path / Path("swagtrace.toml")
129
+
130
+ shutil.copy(template_path, target_path)
131
+
132
+
133
+ def discover_and_save(url: str, output: str, file: str | None = None):
134
+ print(f"Fetching OpenAPI from: {url}")
135
+ spec = fetch_openapi(url=url, file=file)
136
+
137
+ print("Extracting endpoints...")
138
+ endpoints = extract_endpoints(spec)
139
+
140
+ print("Generating yaml file ...")
141
+ save_endpoints_yaml(endpoints, output)
142
+
143
+ print("Creating Test Module ...")
144
+ create_test_module(output)
145
+
146
+ print("Initializing Config File ...")
147
+ init_config_file(output)
148
+
149
+
150
+ def set_initializer_command(
151
+ subparsers: _SubParsersAction, command: str = "init"
152
+ ) -> str:
153
+ init_parser = subparsers.add_parser(command, help="initial project")
154
+ init_parser.add_argument(
155
+ "--url",
156
+ type=str,
157
+ help="url for openapi.json file",
158
+ default="http://localhost:8000/openapi.json",
159
+ )
160
+ init_parser.add_argument(
161
+ "--file",
162
+ type=str,
163
+ help="file of openapi. Json or Yaml format file",
164
+ required=False,
165
+ )
166
+ init_parser.add_argument(
167
+ "-o",
168
+ "--output",
169
+ type=str,
170
+ help="output path for files and folders",
171
+ default=".",
172
+ )
173
+ init_parser.set_defaults(func=discover_and_save)
174
+
175
+ return command
@@ -0,0 +1,346 @@
1
+ import json
2
+ import pprint
3
+ import urllib.error
4
+ import urllib.request
5
+ from argparse import _SubParsersAction
6
+ from functools import partial
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+ from string import Template
11
+ from urllib.parse import parse_qsl, urlparse
12
+
13
+ import yaml
14
+ from yaml_syntax.syntax import YamlSyntax
15
+
16
+ import swagtrace.monkey_patching
17
+ from swagtrace.consts import DEFAULT_TEST_MODULE_FOLDER, DEFAULT_YAML_FILE
18
+ from swagtrace.schemas.yaml_schema import SwagTaceTestFormat, TestCase
19
+ from swagtrace.shared.backend import get_config
20
+ from swagtrace.utils import get_yes_no_user_options, match_path_template
21
+
22
+
23
+ # TODO: This is not a clean structure! Create an independent method or class to store and create test files.
24
+ class APIRecorderProxyHandler(BaseHTTPRequestHandler):
25
+ def __init__(
26
+ self, yaml_syntax: YamlSyntax, host: str, test_module_path: str, *args, **kwargs
27
+ ):
28
+ self.yaml_syntax = yaml_syntax
29
+ self.host = host
30
+ self.test_module_path = Path(test_module_path)
31
+
32
+ self.yaml_schema = yaml_syntax.serialized_data
33
+
34
+ self.PATH_TO_CASES_MAPPER: dict[str, list[TestCase]] = {
35
+ f"{el.method.lower()}-{el.path}": el.cases
36
+ for _, elements in self.yaml_schema.tags.items()
37
+ for el in elements
38
+ }
39
+ self.PATH_TO_TAG_MAPPER: dict[str, str] = {
40
+ el.path: tag
41
+ for tag, elements in self.yaml_schema.tags.items()
42
+ for el in elements
43
+ }
44
+
45
+ config = get_config()
46
+
47
+ # TODO: Also use variables of recorder
48
+ self.BROWSER_NOISE_HEADERS = [
49
+ noise.lower() for noise in config.recorder.header_noise
50
+ ]
51
+ self.IS_ASYNC_FORMAT = config.project.type == "async"
52
+
53
+ super().__init__(*args, **kwargs)
54
+
55
+ def handle_proxy(self):
56
+ method = self.command
57
+ headers = self.headers
58
+ parsed_url = urlparse(self.path)
59
+
60
+ actual_path = parsed_url.path
61
+ if not actual_path.startswith("/"):
62
+ actual_path = "/" + actual_path
63
+
64
+ query_params = parse_qsl(parsed_url.query)
65
+ if query_params:
66
+ query_params = {query[0]: query[1] for query in query_params}
67
+ else:
68
+ query_params = {}
69
+
70
+ content_length = int(headers.get("Content-Length", 0))
71
+ body_bytes = self.rfile.read(content_length) if content_length > 0 else b""
72
+ body_str = body_bytes.decode("utf-8", errors="ignore") if body_bytes else ""
73
+
74
+ clean_headers = {
75
+ k: v
76
+ for k, v in headers.items()
77
+ if k.lower() not in self.BROWSER_NOISE_HEADERS
78
+ }
79
+
80
+ is_api_request = not any(
81
+ actual_path.endswith(ext)
82
+ for ext in [".js", ".css", ".png", ".ico", ".html"]
83
+ )
84
+
85
+ matched_template = None
86
+ variables = {}
87
+
88
+ if f"{method.lower()}-{actual_path}" in self.PATH_TO_CASES_MAPPER:
89
+ matched_template = actual_path
90
+
91
+ else:
92
+ for template in self.PATH_TO_CASES_MAPPER:
93
+ result = match_path_template(
94
+ template, f"{method.lower()}-{actual_path}"
95
+ )
96
+ if result is not None:
97
+ matched_template = template.split("-")[1]
98
+ variables = result
99
+ break
100
+
101
+ full_path = actual_path
102
+ if parsed_url.query:
103
+ full_path += f"?{parsed_url.query}"
104
+
105
+ target_url = f"{self.host}{full_path}"
106
+ forward_headers = {k: v for k, v in headers.items() if k.lower() != "host"}
107
+
108
+ req = urllib.request.Request(
109
+ url=target_url,
110
+ data=body_bytes if body_bytes else None,
111
+ headers=forward_headers,
112
+ method=method,
113
+ )
114
+
115
+ try:
116
+ with urllib.request.urlopen(req) as response:
117
+ res_status = response.status
118
+ res_headers = response.headers
119
+ res_body = response.read()
120
+
121
+ if is_api_request and matched_template is not None:
122
+ self.captured(
123
+ method=method,
124
+ path=matched_template,
125
+ headers=clean_headers,
126
+ req_body=body_str,
127
+ status_code=res_status,
128
+ res_body_bytes=res_body,
129
+ variables=variables,
130
+ query_params=query_params,
131
+ )
132
+
133
+ self.send_response(res_status)
134
+ for k, v in res_headers.items():
135
+ if k.lower() not in ["transfer-encoding", "content-length"]:
136
+ self.send_header(k, v)
137
+ self.send_header("Content-Length", str(len(res_body)))
138
+ self.end_headers()
139
+ self.wfile.write(res_body)
140
+
141
+ except urllib.error.HTTPError as e:
142
+ err_body = e.read()
143
+ err_status = e.status
144
+
145
+ if is_api_request and matched_template is not None:
146
+ self.captured(
147
+ method=method,
148
+ path=matched_template,
149
+ headers=clean_headers,
150
+ req_body=body_str,
151
+ status_code=err_status,
152
+ res_body_bytes=err_body,
153
+ variables=variables,
154
+ query_params=query_params,
155
+ )
156
+
157
+ self.send_response(e.code)
158
+
159
+ for k, v in e.headers.items():
160
+ if k.lower() not in ["transfer-encoding", "content-length"]:
161
+ self.send_header(k, v)
162
+ self.send_header("Content-Length", str(len(err_body)))
163
+ self.end_headers()
164
+ self.wfile.write(err_body)
165
+
166
+ except Exception as e:
167
+ self.send_error(
168
+ 502, f"Bad Gateway: Could not connect to {target_url}. Error: {e!s}"
169
+ )
170
+
171
+ def captured(
172
+ self,
173
+ method,
174
+ path,
175
+ headers,
176
+ req_body,
177
+ status_code,
178
+ res_body_bytes,
179
+ variables,
180
+ query_params,
181
+ ):
182
+ print("\n" + "═" * 60)
183
+ print(f"🎯 [CAPTURED API] {method} {path}")
184
+ print("─" * 60)
185
+
186
+ print("📋 HEADERS:")
187
+ for k, v in headers.items():
188
+ print(f" {k}: {v}")
189
+
190
+ if req_body:
191
+ try:
192
+ req_body = json.loads(req_body)
193
+ except:
194
+ req_body = str(req_body)
195
+
196
+ print("─" * 60)
197
+ print("📦 BODY:")
198
+ print(req_body)
199
+
200
+ print("─" * 60)
201
+ print("💻 REPRODUCIBLE cURL:")
202
+ curl_headers = " ".join([f"-H '{k}: {v}'" for k, v in headers.items()])
203
+ curl_data = f" -d '{req_body}'" if req_body else ""
204
+ print(f"curl -X '{method}' '{self.host}{path}' {curl_headers}{curl_data}")
205
+ print("─" * 60)
206
+ print(f"Status Code: {status_code}")
207
+
208
+ res_body_str = (
209
+ res_body_bytes.decode("utf-8", errors="ignore") if res_body_bytes else ""
210
+ )
211
+ if res_body_str:
212
+ print("📦 RESPONSE BODY:")
213
+ try:
214
+ print(
215
+ json.dumps(json.loads(res_body_str), indent=2, ensure_ascii=False)
216
+ )
217
+ except Exception:
218
+ print(res_body_str)
219
+ print("═" * 60 + "\n")
220
+
221
+ is_user_wants_save_this_case = get_yes_no_user_options(
222
+ "Do you want to save this case"
223
+ )
224
+
225
+ if is_user_wants_save_this_case:
226
+ is_user_wants_save_this_response = get_yes_no_user_options(
227
+ "Also Save Response Content"
228
+ )
229
+
230
+ res_body_str = (
231
+ None if not is_user_wants_save_this_response else res_body_str
232
+ )
233
+
234
+ case_name = input("Enter case name: ")
235
+ case_name = case_name.replace(" ", "_").lower()
236
+
237
+ case = TestCase(
238
+ name=case_name,
239
+ query_params=query_params,
240
+ request_header=headers,
241
+ request_body=req_body,
242
+ status_code=status_code,
243
+ response_content=res_body_str,
244
+ )
245
+
246
+ test_tag_folder = self.test_module_path / Path(
247
+ self.PATH_TO_TAG_MAPPER[path]
248
+ )
249
+ test_tag_folder.mkdir(exist_ok=True)
250
+
251
+ init_test_file = test_tag_folder / Path("__init__.py")
252
+ init_test_file.touch()
253
+
254
+ test_file_path = test_tag_folder / Path(f"{case_name}.py")
255
+
256
+ formatted_vars = pprint.pformat(variables, indent=4)
257
+
258
+ async_prefix = "async " if self.IS_ASYNC_FORMAT else ""
259
+
260
+ template_path = Path(
261
+ files("swagtrace.templates").joinpath("test_script.py.tmpl")
262
+ )
263
+ template_content = template_path.read_text()
264
+
265
+ template = Template(template_content)
266
+ rendered_code = template.substitute(
267
+ variables_dict=formatted_vars, async_prefix=async_prefix
268
+ )
269
+
270
+ with open(test_file_path, "w", encoding="utf-8") as f:
271
+ f.write(rendered_code)
272
+
273
+ # TODO: add validation if case name already exist!
274
+ self.PATH_TO_CASES_MAPPER[f"{method.lower()}-{path}"].append(case)
275
+
276
+ def do_CONNECT(self):
277
+ self.send_error(501, "CONNECT method is not supported by this proxy")
278
+
279
+ def handle_one_request(self):
280
+ try:
281
+ super().handle_one_request()
282
+ except BrokenPipeError:
283
+ pass
284
+ except ConnectionResetError:
285
+ pass
286
+
287
+ do_GET = handle_proxy
288
+ do_POST = handle_proxy
289
+ do_PUT = handle_proxy
290
+ do_DELETE = handle_proxy
291
+ do_PATCH = handle_proxy
292
+ do_HEAD = handle_proxy
293
+ do_OPTIONS = handle_proxy
294
+
295
+
296
+ def run_server(host: str, port: int, file: str, dir: str):
297
+
298
+ yaml_syntax = YamlSyntax.from_file(SwagTaceTestFormat, file)
299
+
300
+ handler_class = partial(APIRecorderProxyHandler, yaml_syntax, host, dir)
301
+
302
+ server_address = ("", port)
303
+ httpd = HTTPServer(server_address, handler_class)
304
+ print(f"🚀 API Recorder Proxy running on http://127.0.0.1:{port}")
305
+ print("🎯 Capturing clean API requests for test replay...\n")
306
+ try:
307
+ httpd.serve_forever()
308
+ except KeyboardInterrupt:
309
+ print("\nStopping proxy server...")
310
+
311
+ file_address = Path(file)
312
+ with file_address.open("w", encoding="utf-8") as f:
313
+ yaml.dump(
314
+ yaml_syntax.to_json,
315
+ f,
316
+ allow_unicode=True,
317
+ sort_keys=False,
318
+ default_flow_style=False,
319
+ )
320
+
321
+ httpd.server_close()
322
+
323
+
324
+ def set_recorder_command(subparsers: _SubParsersAction, command: str = "record") -> str:
325
+ recorder_parser = subparsers.add_parser(command, help="record requests API")
326
+ recorder_parser.add_argument(
327
+ "--host", type=str, help="API Base url", default="http://127.0.0.1:8000"
328
+ )
329
+ recorder_parser.add_argument(
330
+ "--port", type=int, help="Proxy Listen Port", default=8080
331
+ )
332
+ recorder_parser.add_argument(
333
+ "--file",
334
+ type=str,
335
+ help="path of swagtrace.yaml file",
336
+ default=DEFAULT_YAML_FILE,
337
+ )
338
+ recorder_parser.add_argument(
339
+ "--dir",
340
+ type=str,
341
+ help="path of test module",
342
+ default=DEFAULT_TEST_MODULE_FOLDER,
343
+ )
344
+ recorder_parser.set_defaults(func=run_server)
345
+
346
+ return command