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 +0 -0
- swagtrace/__main__.py +5 -0
- swagtrace/cli.py +41 -0
- swagtrace/consts.py +69 -0
- swagtrace/core/__init__.py +1 -0
- swagtrace/core/commands.py +25 -0
- swagtrace/core/initializer.py +175 -0
- swagtrace/core/recorder.py +346 -0
- swagtrace/core/tester.py +271 -0
- swagtrace/helper/__init__.py +2 -0
- swagtrace/helper/run.py +9 -0
- swagtrace/helper/tools.py +111 -0
- swagtrace/monkey_patching.py +8 -0
- swagtrace/schemas/__init__.py +0 -0
- swagtrace/schemas/commands.py +29 -0
- swagtrace/schemas/common.py +6 -0
- swagtrace/schemas/config_schema.py +31 -0
- swagtrace/schemas/variables_schema.py +22 -0
- swagtrace/schemas/yaml_schema.py +29 -0
- swagtrace/shared/__init__.py +8 -0
- swagtrace/shared/backend.py +19 -0
- swagtrace/shared/front.py +30 -0
- swagtrace/templates/config.toml +62 -0
- swagtrace/templates/test_script.py.tmpl +45 -0
- swagtrace/utils.py +312 -0
- swagtrace-0.0.5.dist-info/METADATA +15 -0
- swagtrace-0.0.5.dist-info/RECORD +30 -0
- swagtrace-0.0.5.dist-info/WHEEL +4 -0
- swagtrace-0.0.5.dist-info/entry_points.txt +3 -0
- swagtrace-0.0.5.dist-info/licenses/LICENSE +674 -0
swagtrace/__init__.py
ADDED
|
File without changes
|
swagtrace/__main__.py
ADDED
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
|