overload-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overload/__init__.py +3 -0
- overload/__main__.py +5 -0
- overload/cli.py +393 -0
- overload/collection/__init__.py +1 -0
- overload/collection/environment.py +23 -0
- overload/collection/models.py +88 -0
- overload/collection/parser.py +220 -0
- overload/collection/variables.py +84 -0
- overload/config_file.py +73 -0
- overload/engine/__init__.py +1 -0
- overload/engine/assertions.py +151 -0
- overload/engine/auth.py +87 -0
- overload/engine/events.py +50 -0
- overload/engine/http_client.py +274 -0
- overload/engine/load_patterns.py +730 -0
- overload/engine/models.py +254 -0
- overload/engine/rate_limiter.py +124 -0
- overload/engine/runner.py +86 -0
- overload/report/__init__.py +1 -0
- overload/report/exporters.py +77 -0
- overload/report/generator.py +71 -0
- overload/report/templates/report.html +369 -0
- overload/utils/__init__.py +1 -0
- overload/utils/naming.py +26 -0
- overload/web/__init__.py +1 -0
- overload/web/app.py +38 -0
- overload/web/routes/__init__.py +1 -0
- overload/web/routes/api.py +461 -0
- overload/web/routes/ws.py +77 -0
- overload/web/static/css/app.css +242 -0
- overload/web/static/js/app.js +241 -0
- overload/web/static/js/charts.js +385 -0
- overload/web/static/js/collection.js +344 -0
- overload/web/static/js/runner.js +625 -0
- overload/web/templates/index.html +23 -0
- overload_cli-0.1.0.dist-info/METADATA +267 -0
- overload_cli-0.1.0.dist-info/RECORD +40 -0
- overload_cli-0.1.0.dist-info/WHEEL +4 -0
- overload_cli-0.1.0.dist-info/entry_points.txt +2 -0
- overload_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from overload.collection.models import AuthConfig, ParsedRequest, RequestBody
|
|
9
|
+
from overload.collection.variables import VariableContext
|
|
10
|
+
from overload.engine.models import RequestResult
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HttpClient:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
timeout: float = 30.0,
|
|
19
|
+
verify_ssl: bool = True,
|
|
20
|
+
follow_redirects: bool = True,
|
|
21
|
+
max_connections: int = 100,
|
|
22
|
+
save_responses: bool = False,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._timeout = timeout
|
|
25
|
+
self._verify_ssl = verify_ssl
|
|
26
|
+
self._follow_redirects = follow_redirects
|
|
27
|
+
self._max_connections = max_connections
|
|
28
|
+
self._save_responses = save_responses
|
|
29
|
+
self._client: httpx.AsyncClient | None = None
|
|
30
|
+
|
|
31
|
+
async def __aenter__(self) -> HttpClient:
|
|
32
|
+
self._client = httpx.AsyncClient(
|
|
33
|
+
timeout=httpx.Timeout(self._timeout),
|
|
34
|
+
verify=self._verify_ssl,
|
|
35
|
+
follow_redirects=self._follow_redirects,
|
|
36
|
+
limits=httpx.Limits(
|
|
37
|
+
max_connections=self._max_connections,
|
|
38
|
+
max_keepalive_connections=self._max_connections // 2,
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
async def __aexit__(self, *args: object) -> None:
|
|
44
|
+
if self._client:
|
|
45
|
+
await self._client.aclose()
|
|
46
|
+
self._client = None
|
|
47
|
+
|
|
48
|
+
async def execute(
|
|
49
|
+
self,
|
|
50
|
+
request: ParsedRequest,
|
|
51
|
+
variables: VariableContext | None = None,
|
|
52
|
+
) -> RequestResult:
|
|
53
|
+
if self._client is None:
|
|
54
|
+
raise RuntimeError("HttpClient must be used as an async context manager")
|
|
55
|
+
|
|
56
|
+
ctx = variables or VariableContext()
|
|
57
|
+
|
|
58
|
+
url = ctx.resolve_url(request.url_raw)
|
|
59
|
+
method = request.method
|
|
60
|
+
headers = ctx.resolve_dict(request.headers)
|
|
61
|
+
request_name = request.name
|
|
62
|
+
|
|
63
|
+
query_params = {
|
|
64
|
+
ctx.resolve(q.key): ctx.resolve(q.value)
|
|
65
|
+
for q in request.query_params
|
|
66
|
+
if not q.disabled
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
self._apply_auth(request.auth, headers, ctx, query_params)
|
|
70
|
+
|
|
71
|
+
content, content_headers = self._prepare_body(request.body, ctx)
|
|
72
|
+
headers.update(content_headers)
|
|
73
|
+
|
|
74
|
+
logger.debug(
|
|
75
|
+
"Executing %s %s (name=%s)", method, url, request_name,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
timestamp = time.time()
|
|
79
|
+
t0 = time.monotonic()
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
response = await self._client.request(
|
|
83
|
+
method=method,
|
|
84
|
+
url=url,
|
|
85
|
+
headers=headers,
|
|
86
|
+
params=query_params if query_params else None,
|
|
87
|
+
**content,
|
|
88
|
+
)
|
|
89
|
+
latency_ms = (time.monotonic() - t0) * 1000
|
|
90
|
+
|
|
91
|
+
logger.debug(
|
|
92
|
+
"%s %s -> %d (%.1fms)",
|
|
93
|
+
method, url, response.status_code, latency_ms,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
resp_body = None
|
|
97
|
+
if self._save_responses:
|
|
98
|
+
try:
|
|
99
|
+
resp_body = response.text[:10_000]
|
|
100
|
+
except Exception:
|
|
101
|
+
resp_body = f"<binary {len(response.content)} bytes>"
|
|
102
|
+
|
|
103
|
+
return RequestResult(
|
|
104
|
+
request_name=request_name,
|
|
105
|
+
method=method,
|
|
106
|
+
url=url,
|
|
107
|
+
status_code=response.status_code,
|
|
108
|
+
latency_ms=latency_ms,
|
|
109
|
+
timestamp=timestamp,
|
|
110
|
+
headers_sent=dict(headers),
|
|
111
|
+
headers_received=dict(response.headers),
|
|
112
|
+
body_size_bytes=len(response.content),
|
|
113
|
+
response_body=resp_body,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
except httpx.TimeoutException:
|
|
117
|
+
latency_ms = (time.monotonic() - t0) * 1000
|
|
118
|
+
logger.warning("Timeout: %s %s after %.1fms", method, url, latency_ms)
|
|
119
|
+
return RequestResult(
|
|
120
|
+
request_name=request_name,
|
|
121
|
+
method=method,
|
|
122
|
+
url=url,
|
|
123
|
+
status_code=0,
|
|
124
|
+
latency_ms=latency_ms,
|
|
125
|
+
timestamp=timestamp,
|
|
126
|
+
error="timeout",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
except httpx.ConnectError as exc:
|
|
130
|
+
latency_ms = (time.monotonic() - t0) * 1000
|
|
131
|
+
logger.error("Connection error: %s %s - %s", method, url, exc)
|
|
132
|
+
return RequestResult(
|
|
133
|
+
request_name=request_name,
|
|
134
|
+
method=method,
|
|
135
|
+
url=url,
|
|
136
|
+
status_code=-1,
|
|
137
|
+
latency_ms=latency_ms,
|
|
138
|
+
timestamp=timestamp,
|
|
139
|
+
error=f"connection_error: {exc}",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
except httpx.HTTPError as exc:
|
|
143
|
+
latency_ms = (time.monotonic() - t0) * 1000
|
|
144
|
+
logger.error("HTTP error: %s %s - %s", method, url, exc)
|
|
145
|
+
return RequestResult(
|
|
146
|
+
request_name=request_name,
|
|
147
|
+
method=method,
|
|
148
|
+
url=url,
|
|
149
|
+
status_code=-1,
|
|
150
|
+
latency_ms=latency_ms,
|
|
151
|
+
timestamp=timestamp,
|
|
152
|
+
error=str(exc),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def prepare_collection_auth(
|
|
156
|
+
self,
|
|
157
|
+
auth: AuthConfig | None,
|
|
158
|
+
ctx: VariableContext,
|
|
159
|
+
) -> None:
|
|
160
|
+
if not auth or auth.type != "oauth2":
|
|
161
|
+
return
|
|
162
|
+
if self._client is None:
|
|
163
|
+
raise RuntimeError("HttpClient must be used as an async context manager")
|
|
164
|
+
from overload.engine.auth import fetch_oauth2_token
|
|
165
|
+
token = await fetch_oauth2_token(auth, ctx, self._client)
|
|
166
|
+
ctx.set_variable("_oauth2_access_token", token)
|
|
167
|
+
logger.info("OAuth2: access token ready, injected into variable context")
|
|
168
|
+
|
|
169
|
+
def _apply_auth(
|
|
170
|
+
self,
|
|
171
|
+
auth: AuthConfig | None,
|
|
172
|
+
headers: dict[str, str],
|
|
173
|
+
ctx: VariableContext,
|
|
174
|
+
query_params: dict[str, str] | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
if not auth:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
if auth.type == "bearer":
|
|
180
|
+
token = ctx.resolve(auth.params.get("token", ""))
|
|
181
|
+
if token:
|
|
182
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
183
|
+
|
|
184
|
+
elif auth.type == "basic":
|
|
185
|
+
import base64
|
|
186
|
+
username = ctx.resolve(auth.params.get("username", ""))
|
|
187
|
+
password = ctx.resolve(auth.params.get("password", ""))
|
|
188
|
+
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
189
|
+
headers["Authorization"] = f"Basic {credentials}"
|
|
190
|
+
|
|
191
|
+
elif auth.type == "apikey":
|
|
192
|
+
key = ctx.resolve(auth.params.get("key", ""))
|
|
193
|
+
value = ctx.resolve(auth.params.get("value", ""))
|
|
194
|
+
location = auth.params.get("in", "header")
|
|
195
|
+
if location == "header" and key:
|
|
196
|
+
headers[key] = value
|
|
197
|
+
elif location == "query" and key and query_params is not None:
|
|
198
|
+
query_params[key] = value
|
|
199
|
+
|
|
200
|
+
elif auth.type == "oauth2":
|
|
201
|
+
token = ctx.resolve("{{_oauth2_access_token}}")
|
|
202
|
+
if token and token != "{{_oauth2_access_token}}":
|
|
203
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
204
|
+
else:
|
|
205
|
+
logger.warning(
|
|
206
|
+
"OAuth2: no access token found in context — "
|
|
207
|
+
"prepare_collection_auth() may not have been called"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
else:
|
|
211
|
+
logger.warning(
|
|
212
|
+
"Unsupported auth type: %s — request will be sent without authentication",
|
|
213
|
+
auth.type,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def _prepare_body(
|
|
217
|
+
self,
|
|
218
|
+
body: RequestBody,
|
|
219
|
+
ctx: VariableContext,
|
|
220
|
+
) -> tuple[dict, dict[str, str]]:
|
|
221
|
+
extra_headers: dict[str, str] = {}
|
|
222
|
+
|
|
223
|
+
if body.mode == "none":
|
|
224
|
+
return {}, extra_headers
|
|
225
|
+
|
|
226
|
+
if body.mode == "raw":
|
|
227
|
+
content_str = ctx.resolve(str(body.content))
|
|
228
|
+
if body.content_type:
|
|
229
|
+
extra_headers["Content-Type"] = body.content_type
|
|
230
|
+
return {"content": content_str}, extra_headers
|
|
231
|
+
|
|
232
|
+
if body.mode == "urlencoded":
|
|
233
|
+
fields = body.content if isinstance(body.content, list) else []
|
|
234
|
+
data = {
|
|
235
|
+
ctx.resolve(f["key"]): ctx.resolve(f.get("value", ""))
|
|
236
|
+
for f in fields
|
|
237
|
+
if isinstance(f, dict)
|
|
238
|
+
}
|
|
239
|
+
return {"data": data}, extra_headers
|
|
240
|
+
|
|
241
|
+
if body.mode == "formdata":
|
|
242
|
+
fields = body.content if isinstance(body.content, list) else []
|
|
243
|
+
files = {}
|
|
244
|
+
data = {}
|
|
245
|
+
for f in fields:
|
|
246
|
+
if not isinstance(f, dict):
|
|
247
|
+
continue
|
|
248
|
+
key = ctx.resolve(f["key"])
|
|
249
|
+
if f.get("type") == "file":
|
|
250
|
+
file_path = f.get("value", "") or f.get("src", "")
|
|
251
|
+
try:
|
|
252
|
+
files[key] = open(file_path, "rb")
|
|
253
|
+
except (OSError, FileNotFoundError):
|
|
254
|
+
logger.warning("Cannot open file: %s", file_path)
|
|
255
|
+
else:
|
|
256
|
+
data[key] = ctx.resolve(f.get("value", ""))
|
|
257
|
+
kwargs: dict = {}
|
|
258
|
+
if data:
|
|
259
|
+
kwargs["data"] = data
|
|
260
|
+
if files:
|
|
261
|
+
kwargs["files"] = files
|
|
262
|
+
return kwargs, extra_headers
|
|
263
|
+
|
|
264
|
+
if body.mode == "graphql":
|
|
265
|
+
import json
|
|
266
|
+
gql = body.content if isinstance(body.content, dict) else {}
|
|
267
|
+
payload = {
|
|
268
|
+
"query": ctx.resolve(gql.get("query", "")),
|
|
269
|
+
"variables": ctx.resolve(gql.get("variables", "{}")),
|
|
270
|
+
}
|
|
271
|
+
extra_headers["Content-Type"] = "application/json"
|
|
272
|
+
return {"content": json.dumps(payload)}, extra_headers
|
|
273
|
+
|
|
274
|
+
return {}, extra_headers
|