stoobly-agent 1.10.1__py3-none-any.whl → 1.10.2__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.
- stoobly_agent/__init__.py +1 -1
- stoobly_agent/__main__.py +10 -0
- stoobly_agent/app/cli/ca_cert_cli.py +9 -5
- stoobly_agent/app/cli/helpers/replay_facade.py +2 -2
- stoobly_agent/app/cli/intercept_cli.py +5 -5
- stoobly_agent/app/cli/request_cli.py +2 -2
- stoobly_agent/app/cli/scaffold/app.py +14 -5
- stoobly_agent/app/cli/scaffold/app_command.py +0 -4
- stoobly_agent/app/cli/scaffold/app_config.py +49 -2
- stoobly_agent/app/cli/scaffold/app_create_command.py +145 -76
- stoobly_agent/app/cli/scaffold/constants.py +8 -1
- stoobly_agent/app/cli/scaffold/docker/constants.py +3 -1
- stoobly_agent/app/cli/scaffold/docker/service/build_decorator.py +2 -2
- stoobly_agent/app/cli/scaffold/docker/service/builder.py +15 -49
- stoobly_agent/app/cli/scaffold/docker/service/configure_gateway.py +3 -0
- stoobly_agent/app/cli/scaffold/docker/template_files.py +112 -0
- stoobly_agent/app/cli/scaffold/docker/workflow/build_decorator.py +1 -1
- stoobly_agent/app/cli/scaffold/docker/workflow/builder.py +31 -39
- stoobly_agent/app/cli/scaffold/docker/workflow/command_decorator.py +1 -1
- stoobly_agent/app/cli/scaffold/docker/workflow/detached_decorator.py +1 -1
- stoobly_agent/app/cli/scaffold/docker/workflow/dns_decorator.py +2 -3
- stoobly_agent/app/cli/scaffold/docker/workflow/local_decorator.py +1 -1
- stoobly_agent/app/cli/scaffold/docker/workflow/mock_decorator.py +1 -1
- stoobly_agent/app/cli/scaffold/docker/workflow/reverse_proxy_decorator.py +1 -1
- stoobly_agent/app/cli/scaffold/docker/workflow/run_command.py +423 -0
- stoobly_agent/app/cli/scaffold/local/__init__.py +0 -0
- stoobly_agent/app/cli/scaffold/local/service/__init__.py +0 -0
- stoobly_agent/app/cli/scaffold/local/service/builder.py +72 -0
- stoobly_agent/app/cli/scaffold/local/workflow/__init__.py +0 -0
- stoobly_agent/app/cli/scaffold/local/workflow/builder.py +35 -0
- stoobly_agent/app/cli/scaffold/local/workflow/run_command.py +339 -0
- stoobly_agent/app/cli/scaffold/service_command.py +9 -1
- stoobly_agent/app/cli/scaffold/service_config.py +8 -0
- stoobly_agent/app/cli/scaffold/service_create_command.py +18 -6
- stoobly_agent/app/cli/scaffold/service_docker_compose.py +1 -1
- stoobly_agent/app/cli/scaffold/templates/app/.Makefile +2 -2
- stoobly_agent/app/cli/scaffold/templates/app/build/.docker-compose.base.yml +2 -2
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/.docker-compose.base.yml +2 -2
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/run +3 -0
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/run +3 -0
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/run +3 -0
- stoobly_agent/app/cli/scaffold/templates/build/services/build/mock/.configure +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/build/mock/.init +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/build/mock/.run +14 -0
- stoobly_agent/app/cli/scaffold/templates/build/services/build/record/.configure +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/build/record/.init +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/build/record/.run +14 -0
- stoobly_agent/app/cli/scaffold/templates/build/services/build/test/.configure +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/build/test/.init +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/build/test/.run +14 -0
- stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/mock/.configure +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/mock/.init +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/mock/.run +19 -0
- stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/record/.configure +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/record/.init +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/record/.run +19 -0
- stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/test/.configure +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/test/.init +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/test/.run +19 -0
- stoobly_agent/app/cli/scaffold/templates/build/workflows/exec/scaffold/.up +0 -1
- stoobly_agent/app/cli/scaffold/templates/build/workflows/mock/.configure +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/workflows/mock/.init +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/workflows/mock/.run +14 -0
- stoobly_agent/app/cli/scaffold/templates/build/workflows/record/.configure +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/workflows/record/.init +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/workflows/record/.run +14 -0
- stoobly_agent/app/cli/scaffold/templates/build/workflows/test/.configure +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/workflows/test/.init +5 -1
- stoobly_agent/app/cli/scaffold/templates/build/workflows/test/.run +14 -0
- stoobly_agent/app/cli/scaffold/templates/constants.py +35 -19
- stoobly_agent/app/cli/scaffold/templates/factory.py +34 -18
- stoobly_agent/app/cli/scaffold/templates/plugins/cypress/test/.run +21 -0
- stoobly_agent/app/cli/scaffold/templates/plugins/playwright/test/.run +21 -0
- stoobly_agent/app/cli/scaffold/templates/workflow/mock/run +3 -0
- stoobly_agent/app/cli/scaffold/templates/workflow/record/run +3 -0
- stoobly_agent/app/cli/scaffold/templates/workflow/test/run +3 -0
- stoobly_agent/app/cli/scaffold/workflow_command.py +18 -4
- stoobly_agent/app/cli/scaffold/workflow_copy_command.py +5 -4
- stoobly_agent/app/cli/scaffold/workflow_create_command.py +31 -29
- stoobly_agent/app/cli/scaffold/workflow_run_command.py +18 -151
- stoobly_agent/app/cli/scaffold_cli.py +115 -161
- stoobly_agent/app/cli/scenario_cli.py +2 -2
- stoobly_agent/app/cli/types/test.py +2 -2
- stoobly_agent/app/cli/types/workflow_run_command.py +52 -3
- stoobly_agent/app/proxy/handle_mock_service.py +1 -1
- stoobly_agent/app/proxy/intercept_settings.py +5 -25
- stoobly_agent/app/proxy/mock/eval_fixtures_service.py +177 -27
- stoobly_agent/app/proxy/mock/types/__init__.py +22 -1
- stoobly_agent/app/proxy/replay/body_parser_service.py +8 -5
- stoobly_agent/app/proxy/replay/multipart.py +15 -13
- stoobly_agent/app/proxy/replay/replay_request_service.py +2 -2
- stoobly_agent/app/proxy/run.py +3 -0
- stoobly_agent/app/proxy/test/context.py +0 -4
- stoobly_agent/app/proxy/test/context_abc.py +0 -5
- stoobly_agent/cli.py +61 -16
- stoobly_agent/config/data_dir.py +0 -8
- stoobly_agent/public/12-es2015.618ecfd5f735b801b50f.js +1 -0
- stoobly_agent/public/12-es5.618ecfd5f735b801b50f.js +1 -0
- stoobly_agent/public/index.html +1 -1
- stoobly_agent/public/runtime-es2015.77bcd31efed9e5d5d431.js +1 -0
- stoobly_agent/public/runtime-es5.77bcd31efed9e5d5d431.js +1 -0
- stoobly_agent/test/app/cli/intercept/intercept_configure_test.py +17 -6
- stoobly_agent/test/app/cli/scaffold/docker/cli_invoker.py +177 -0
- stoobly_agent/test/app/cli/scaffold/{cli_test.py → docker/cli_test.py} +1 -8
- stoobly_agent/test/app/cli/scaffold/{e2e_test.py → docker/e2e_test.py} +31 -16
- stoobly_agent/test/app/cli/scaffold/local/__init__.py +0 -0
- stoobly_agent/test/app/cli/scaffold/{cli_invoker.py → local/cli_invoker.py} +38 -32
- stoobly_agent/test/app/cli/scaffold/local/e2e_test.py +342 -0
- stoobly_agent/test/app/models/schemas/.stoobly/db/VERSION +1 -1
- stoobly_agent/test/app/proxy/mock/eval_fixtures_service_test.py +903 -2
- stoobly_agent/test/app/proxy/replay/body_parser_service_test.py +95 -3
- stoobly_agent/test/config/data_dir_test.py +2 -7
- stoobly_agent/test/test_helper.py +16 -5
- {stoobly_agent-1.10.1.dist-info → stoobly_agent-1.10.2.dist-info}/METADATA +4 -2
- {stoobly_agent-1.10.1.dist-info → stoobly_agent-1.10.2.dist-info}/RECORD +150 -122
- {stoobly_agent-1.10.1.dist-info → stoobly_agent-1.10.2.dist-info}/WHEEL +1 -1
- stoobly_agent/app/cli/helpers/shell.py +0 -26
- stoobly_agent/public/12-es2015.be58ed0ef449008b932e.js +0 -1
- stoobly_agent/public/12-es5.be58ed0ef449008b932e.js +0 -1
- stoobly_agent/public/runtime-es2015.f8c814b38b27708e91c1.js +0 -1
- stoobly_agent/public/runtime-es5.f8c814b38b27708e91c1.js +0 -1
- /stoobly_agent/app/cli/scaffold/templates/app/build/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/build/mock/{bin/configure → configure} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/build/mock/{bin/init → init} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/build/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/build/record/{bin/configure → configure} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/build/record/{bin/init → init} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/build/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/build/test/{bin/configure → configure} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/build/test/{bin/init → init} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/{bin/configure → configure} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/{bin/init → init} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/{bin/configure → configure} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/{bin/init → init} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/{bin/configure → configure} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/{bin/init → init} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/gateway/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/gateway/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/gateway/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/stoobly-ui/exec/{.docker-compose.exec.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/stoobly-ui/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/app/stoobly-ui/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/plugins/cypress/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/plugins/playwright/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/workflow/mock/{bin/configure → configure} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/workflow/mock/{bin/init → init} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/workflow/record/{bin/configure → configure} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/workflow/record/{bin/init → init} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/workflow/test/{bin/configure → configure} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/workflow/test/{bin/init → init} +0 -0
- {stoobly_agent-1.10.1.dist-info → stoobly_agent-1.10.2.dist-info}/entry_points.txt +0 -0
- {stoobly_agent-1.10.1.dist-info → stoobly_agent-1.10.2.dist-info/licenses}/LICENSE +0 -0
@@ -2,25 +2,23 @@ import mimetypes
|
|
2
2
|
import os
|
3
3
|
import pdb
|
4
4
|
import re
|
5
|
+
import yaml
|
5
6
|
|
6
7
|
from io import BytesIO
|
7
8
|
from mitmproxy.http import Request as MitmproxyRequest
|
8
9
|
from requests import Response
|
9
10
|
from requests.structures import CaseInsensitiveDict
|
10
|
-
from typing import Optional, Union
|
11
|
+
from typing import List, Optional, Union
|
12
|
+
from urllib.parse import urlparse
|
11
13
|
|
12
14
|
from stoobly_agent.lib.logger import bcolors, Logger
|
13
15
|
from stoobly_agent.config.constants.custom_headers import MOCK_FIXTURE_PATH
|
14
16
|
|
15
|
-
from .types import Fixtures
|
17
|
+
from .types import Fixtures, MockOptions, PublicDirectoryPath, ResponseFixturesPath
|
16
18
|
|
17
19
|
LOG_ID = 'Fixture'
|
18
20
|
|
19
|
-
|
20
|
-
public_directory_path: str
|
21
|
-
response_fixtures: Fixtures
|
22
|
-
|
23
|
-
def eval_fixtures(request: MitmproxyRequest, **options: Options) -> Union[Response, None]:
|
21
|
+
def eval_fixtures(request: MitmproxyRequest, **options: MockOptions) -> Union[Response, None]:
|
24
22
|
fixture_path = request.headers.get(MOCK_FIXTURE_PATH)
|
25
23
|
headers = CaseInsensitiveDict()
|
26
24
|
status_code = 200
|
@@ -30,23 +28,49 @@ def eval_fixtures(request: MitmproxyRequest, **options: Options) -> Union[Respon
|
|
30
28
|
return
|
31
29
|
else:
|
32
30
|
response_fixtures = options.get('response_fixtures')
|
33
|
-
|
31
|
+
|
32
|
+
# Try response fixtures in order of preference
|
33
|
+
fixture = None
|
34
34
|
|
35
|
-
if
|
36
|
-
|
35
|
+
if response_fixtures:
|
36
|
+
fixture = __find_fixture_for_request(request, response_fixtures, request.method)
|
37
37
|
|
38
|
-
|
38
|
+
if options.get('response_fixtures_path'):
|
39
|
+
fixture = __eval_response_fixtures_from_paths(request, options['response_fixtures_path'])
|
40
|
+
|
41
|
+
if not fixture:
|
42
|
+
raw_paths = options.get('public_directory_path')
|
43
|
+
public_directory_paths = __parse_public_directory_paths(raw_paths)
|
44
|
+
|
45
|
+
if not public_directory_paths:
|
39
46
|
return
|
40
47
|
|
41
48
|
request_path = 'index' if request.path == '/' else request.path
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
49
|
+
# Extract origin from request URL (e.g., https://example.com/path -> https://example.com)
|
50
|
+
request_origin = __request_origin(request)
|
51
|
+
|
52
|
+
# Try to find a matching file in the public directory paths
|
53
|
+
fixture_path = None
|
54
|
+
for dir_path_config in public_directory_paths:
|
55
|
+
# Check if origin matches (if origin is specified)
|
56
|
+
if dir_path_config.get('origin') and request_origin:
|
57
|
+
if not __origin_matches(dir_path_config['origin'], request_origin):
|
58
|
+
continue
|
59
|
+
|
60
|
+
# Try to find the file in this directory
|
61
|
+
_fixture_path = os.path.join(dir_path_config['path'], request_path.lstrip('/'))
|
62
|
+
if request.headers.get('accept'):
|
63
|
+
fixture_path = __guess_file_path(_fixture_path, request.headers['accept'])
|
64
|
+
|
65
|
+
if not fixture_path:
|
66
|
+
fixture_path = _fixture_path
|
67
|
+
|
68
|
+
if os.path.isfile(fixture_path):
|
69
|
+
break
|
70
|
+
else:
|
71
|
+
fixture_path = None
|
72
|
+
|
46
73
|
if not fixture_path:
|
47
|
-
fixture_path = _fixture_path
|
48
|
-
|
49
|
-
if not os.path.isfile(fixture_path):
|
50
74
|
return
|
51
75
|
else:
|
52
76
|
fixture_path = fixture.get('path')
|
@@ -81,6 +105,53 @@ def eval_fixtures(request: MitmproxyRequest, **options: Options) -> Union[Respon
|
|
81
105
|
|
82
106
|
return response
|
83
107
|
|
108
|
+
def __eval_response_fixtures_from_paths(request: MitmproxyRequest, fixtures_paths: str):
|
109
|
+
"""Iterate through response fixtures paths and return the first matching fixture."""
|
110
|
+
if not fixtures_paths:
|
111
|
+
return None
|
112
|
+
|
113
|
+
# Parse multiple response fixtures paths with optional origin specification
|
114
|
+
parsed_paths = __parse_response_fixtures_paths(fixtures_paths)
|
115
|
+
|
116
|
+
# Extract origin from request URL
|
117
|
+
request_origin = __request_origin(request)
|
118
|
+
method = request.method
|
119
|
+
|
120
|
+
# Iterate through each fixtures path
|
121
|
+
for path_config in parsed_paths:
|
122
|
+
fixtures_path = path_config['path']
|
123
|
+
origin = path_config.get('origin')
|
124
|
+
|
125
|
+
# If origin is specified, check if it matches the request origin
|
126
|
+
if origin and request_origin:
|
127
|
+
if not __origin_matches(origin, request_origin):
|
128
|
+
continue # Skip this file if origin doesn't match
|
129
|
+
elif origin and not request_origin:
|
130
|
+
continue # Skip origin-specific files if request has no origin
|
131
|
+
|
132
|
+
# Load and parse this specific fixtures file
|
133
|
+
if not os.path.exists(fixtures_path):
|
134
|
+
Logger.instance(LOG_ID).error(f"Response fixtures {fixtures_path} does not exist")
|
135
|
+
continue
|
136
|
+
|
137
|
+
try:
|
138
|
+
with open(fixtures_path, 'r') as stream:
|
139
|
+
fixtures = yaml.safe_load(stream) or {}
|
140
|
+
|
141
|
+
# Try to find a matching fixture in this file
|
142
|
+
fixture = __find_fixture_for_request(request, fixtures, method)
|
143
|
+
if fixture:
|
144
|
+
# Convert fixture path to absolute path if it's a relative path and if 'path' exists in the fixture
|
145
|
+
if fixture.get('path') and not os.path.isabs(fixture['path']):
|
146
|
+
fixture['path'] = os.path.join(os.path.dirname(fixtures_path), fixture['path'])
|
147
|
+
return fixture # Return immediately on first match
|
148
|
+
|
149
|
+
except yaml.YAMLError as exc:
|
150
|
+
Logger.instance(LOG_ID).error(f"Error parsing {fixtures_path}: {exc}")
|
151
|
+
continue
|
152
|
+
|
153
|
+
return None # No matching fixture found in any file
|
154
|
+
|
84
155
|
def __guess_content_type(file_path):
|
85
156
|
file_extension = os.path.splitext(file_path)[1]
|
86
157
|
if not file_extension:
|
@@ -107,18 +178,22 @@ def __guess_file_path(file_path, content_type):
|
|
107
178
|
if os.path.isfile(_file_path):
|
108
179
|
return _file_path
|
109
180
|
|
110
|
-
def
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
181
|
+
def __find_fixture_for_request(request: MitmproxyRequest, fixtures: dict, method: str):
|
182
|
+
"""Find a fixture for the given request in the provided fixtures."""
|
183
|
+
if not fixtures:
|
184
|
+
return None
|
185
|
+
|
186
|
+
return __find_fixture_in_routes(fixtures, method, request.path)
|
187
|
+
|
188
|
+
def __find_fixture_in_routes(fixtures: dict, method: str, request_path: str):
|
189
|
+
"""Find a fixture for the given method and path in the provided fixtures."""
|
190
|
+
routes = fixtures.get(method)
|
191
|
+
|
117
192
|
if not isinstance(routes, dict):
|
118
|
-
return
|
193
|
+
return None
|
119
194
|
|
120
195
|
for path_pattern in routes:
|
121
|
-
if not re.match(path_pattern,
|
196
|
+
if not re.match(path_pattern, request_path):
|
122
197
|
continue
|
123
198
|
|
124
199
|
fixture = routes[path_pattern]
|
@@ -129,6 +204,8 @@ def __eval_response_fixtures(request: MitmproxyRequest, response_fixtures: Fixtu
|
|
129
204
|
|
130
205
|
if path:
|
131
206
|
return fixture
|
207
|
+
|
208
|
+
return None
|
132
209
|
|
133
210
|
def __choose_highest_priority_content_type(accept_header: str) -> Optional[str]:
|
134
211
|
if not accept_header:
|
@@ -156,6 +233,9 @@ def __choose_highest_priority_content_type(accept_header: str) -> Optional[str]:
|
|
156
233
|
types.sort(key=lambda x: -x[1])
|
157
234
|
return types[0][0] if types else None
|
158
235
|
|
236
|
+
def __origin_matches(pattern: str, request_origin: str) -> bool:
|
237
|
+
return bool(re.match(pattern, request_origin))
|
238
|
+
|
159
239
|
def __parse_accept_header(accept_header):
|
160
240
|
types = []
|
161
241
|
for item in accept_header.split(","):
|
@@ -171,3 +251,73 @@ def __parse_accept_header(accept_header):
|
|
171
251
|
|
172
252
|
# Sort by quality factor in descending order
|
173
253
|
return [content_type for content_type, _ in sorted(types, key=lambda x: x[1], reverse=True)]
|
254
|
+
|
255
|
+
def __parse_origin_path_item(path_item: str):
|
256
|
+
"""Parse a single path:origin item and return (path, origin) tuple."""
|
257
|
+
path_item = path_item.strip()
|
258
|
+
|
259
|
+
# Check if this looks like a path:origin format
|
260
|
+
# Format: <FILE-PATH>:<ORIGIN> where ORIGIN is scheme:hostname:port
|
261
|
+
# We need to find the colon that separates path from origin
|
262
|
+
if '://' in path_item:
|
263
|
+
# Find the colon that separates path from origin
|
264
|
+
# Origin format: scheme:hostname:port (e.g., https://api.example.com:8080)
|
265
|
+
origin_start = path_item.rfind('://')
|
266
|
+
if origin_start > 0:
|
267
|
+
# Look for the colon before the scheme
|
268
|
+
colon_before_scheme = path_item.rfind(':', 0, origin_start)
|
269
|
+
if colon_before_scheme != -1:
|
270
|
+
# Format: path:origin
|
271
|
+
path = path_item[:colon_before_scheme]
|
272
|
+
origin = path_item[colon_before_scheme + 1:]
|
273
|
+
return (path.strip(), origin.strip())
|
274
|
+
|
275
|
+
# No colon before scheme found, treat entire string as path
|
276
|
+
return (path_item, None)
|
277
|
+
else:
|
278
|
+
# Check for path:hostname:port format (without scheme)
|
279
|
+
colons = [i for i, char in enumerate(path_item) if char == ':']
|
280
|
+
|
281
|
+
if len(colons) >= 1:
|
282
|
+
# Find the first colon that separates path from origin
|
283
|
+
first_colon_idx = colons[0]
|
284
|
+
potential_origin = path_item[first_colon_idx + 1:]
|
285
|
+
|
286
|
+
# Check if this looks like a hostname:port format
|
287
|
+
# A valid hostname:port should have another colon for the port
|
288
|
+
if ':' in potential_origin:
|
289
|
+
# Format: path:hostname:port
|
290
|
+
path = path_item[:first_colon_idx]
|
291
|
+
origin = potential_origin
|
292
|
+
return (path.strip(), origin.strip())
|
293
|
+
|
294
|
+
# No valid path:origin format found, treat entire string as path
|
295
|
+
return (path_item, None)
|
296
|
+
|
297
|
+
def __parse_public_directory_paths(raw_paths: str) -> List[PublicDirectoryPath]:
|
298
|
+
"""Parse public directory paths from comma-separated string."""
|
299
|
+
if not raw_paths:
|
300
|
+
return []
|
301
|
+
|
302
|
+
paths = []
|
303
|
+
for path_item in raw_paths.split(','):
|
304
|
+
path, origin = __parse_origin_path_item(path_item)
|
305
|
+
paths.append(PublicDirectoryPath(origin=origin, path=path))
|
306
|
+
|
307
|
+
return paths
|
308
|
+
|
309
|
+
def __parse_response_fixtures_paths(raw_paths: str) -> List[ResponseFixturesPath]:
|
310
|
+
"""Parse response fixtures paths from comma-separated string."""
|
311
|
+
if not raw_paths:
|
312
|
+
return []
|
313
|
+
|
314
|
+
paths = []
|
315
|
+
for path_item in raw_paths.split(','):
|
316
|
+
path, origin = __parse_origin_path_item(path_item)
|
317
|
+
paths.append(ResponseFixturesPath(origin=origin, path=path))
|
318
|
+
|
319
|
+
return paths
|
320
|
+
|
321
|
+
def __request_origin(request: MitmproxyRequest) -> str:
|
322
|
+
parsed_url = urlparse(request.url)
|
323
|
+
return f"{parsed_url.scheme}://{parsed_url.hostname}" + (f":{parsed_url.port}" if parsed_url.port else "")
|
@@ -1,4 +1,4 @@
|
|
1
|
-
from typing import TypedDict
|
1
|
+
from typing import TypedDict, Optional
|
2
2
|
|
3
3
|
class Route(TypedDict):
|
4
4
|
path: str
|
@@ -8,3 +8,24 @@ class Fixtures(TypedDict):
|
|
8
8
|
GET: Route
|
9
9
|
POST: Route
|
10
10
|
PUT: Route
|
11
|
+
|
12
|
+
class PublicDirectoryPath(TypedDict):
|
13
|
+
"""Represents a public directory path with optional origin specification.
|
14
|
+
|
15
|
+
Format: <FOLDER-PATH>:<ORIGIN> where ORIGIN is scheme:hostname:port
|
16
|
+
"""
|
17
|
+
origin: Optional[str] # Optional origin (e.g., "https://api.example.com:8080")
|
18
|
+
path: str # File system path to the public directory
|
19
|
+
|
20
|
+
class ResponseFixturesPath(TypedDict):
|
21
|
+
"""Represents a response fixtures file path with optional origin specification.
|
22
|
+
|
23
|
+
Format: <FILE-PATH>:<ORIGIN> where ORIGIN is scheme:hostname:port
|
24
|
+
"""
|
25
|
+
origin: Optional[str] # Optional origin (e.g., "https://api.example.com:8080")
|
26
|
+
path: str # File system path to the response fixtures file
|
27
|
+
|
28
|
+
class MockOptions(TypedDict):
|
29
|
+
"""Options for mock service."""
|
30
|
+
public_directory_path: str
|
31
|
+
response_fixtures_path: str # Comma-separated list of paths, optionally with origin prefix
|
@@ -69,13 +69,16 @@ def parse_json(content):
|
|
69
69
|
return content
|
70
70
|
|
71
71
|
def parse_multipart_form_data(content, content_type) -> Dict[bytes, bytes]:
|
72
|
-
|
73
|
-
|
72
|
+
try:
|
73
|
+
headers = {'content-type': content_type}
|
74
|
+
decoded_multipart = multipart_decode(headers, content)
|
74
75
|
|
75
|
-
|
76
|
+
if not decoded_multipart:
|
77
|
+
return content
|
78
|
+
|
79
|
+
return MultiDict(decoded_multipart)
|
80
|
+
except:
|
76
81
|
return content
|
77
|
-
|
78
|
-
return MultiDict(decoded_multipart)
|
79
82
|
|
80
83
|
def parse_www_form_urlencoded(content):
|
81
84
|
try:
|
@@ -3,7 +3,7 @@ import mimetypes
|
|
3
3
|
import re
|
4
4
|
import pdb
|
5
5
|
|
6
|
-
from multipart import MultipartParser
|
6
|
+
from multipart import MultipartParser, ParserError
|
7
7
|
from urllib.parse import quote
|
8
8
|
|
9
9
|
from mitmproxy.net.http import headers
|
@@ -51,29 +51,31 @@ def decode(hdrs, content):
|
|
51
51
|
Takes a multipart boundary encoded string and returns list of (key, value) tuples.
|
52
52
|
"""
|
53
53
|
if not isinstance(content, bytes) and not isinstance(content, str):
|
54
|
-
return
|
54
|
+
return content
|
55
55
|
|
56
56
|
v = hdrs.get("content-type")
|
57
57
|
if not v:
|
58
|
-
return
|
58
|
+
return content
|
59
59
|
|
60
60
|
v = headers.parse_content_type(v)
|
61
61
|
if not v:
|
62
|
-
return
|
62
|
+
return content
|
63
63
|
|
64
64
|
try:
|
65
65
|
boundary = v[2]["boundary"].encode("ascii")
|
66
66
|
except (KeyError, UnicodeError):
|
67
|
-
return
|
67
|
+
return content
|
68
68
|
|
69
69
|
r = []
|
70
|
-
|
71
|
-
|
72
|
-
|
70
|
+
|
71
|
+
try:
|
72
|
+
parser = MultipartParser(io.BytesIO(content), boundary=boundary)
|
73
|
+
|
74
|
+
for part in parser.parts():
|
73
75
|
r.append((part.name, part.raw))
|
74
|
-
else:
|
75
|
-
r.append((part.name, part.value))
|
76
76
|
|
77
|
-
|
78
|
-
|
79
|
-
|
77
|
+
# Free up resources after use
|
78
|
+
part.close()
|
79
|
+
return r
|
80
|
+
except ParserError:
|
81
|
+
return content
|
@@ -27,11 +27,11 @@ class ReplayRequestOptions(TypedDict):
|
|
27
27
|
after_replay: Union[Callable[[ReplayContext], Union[requests.Response, None]], None]
|
28
28
|
project_key: Union[str, None]
|
29
29
|
proxies: dict
|
30
|
-
public_directory_path: str
|
30
|
+
public_directory_path: str # Comma-separated list of paths, optionally with origin prefix
|
31
31
|
remote_project_key: str
|
32
32
|
report_key: Union[str, None]
|
33
33
|
request_origin: Union[request_origin.CLI, None]
|
34
|
-
response_fixtures_path: str
|
34
|
+
response_fixtures_path: str # Comma-separated list of paths, optionally with origin prefix
|
35
35
|
response_mode: Union[mode.RECORD, None]
|
36
36
|
scenario_key: Union[str, None]
|
37
37
|
scheme: str
|
stoobly_agent/app/proxy/run.py
CHANGED
@@ -105,6 +105,9 @@ def __filter_options(options):
|
|
105
105
|
if 'cert_passphrase' in options and not options['cert_passphrase']:
|
106
106
|
del options['cert_passphrase']
|
107
107
|
|
108
|
+
if 'detached' in options:
|
109
|
+
del options['detached']
|
110
|
+
|
108
111
|
if 'headless' in options:
|
109
112
|
del options['headless']
|
110
113
|
|
@@ -166,10 +166,6 @@ class TestContext(TestContextABC):
|
|
166
166
|
def response(self) -> TestContextResponse:
|
167
167
|
return self.__response
|
168
168
|
|
169
|
-
@property
|
170
|
-
def response_fixtures(self):
|
171
|
-
return self.__intercept_settings.response_fixtures
|
172
|
-
|
173
169
|
@property
|
174
170
|
def response_fixtures_path(self):
|
175
171
|
return self.__intercept_settings.response_fixtures_path
|
@@ -149,11 +149,6 @@ class TestContextABC(abc.ABC):
|
|
149
149
|
def response(self) -> TestContextResponse:
|
150
150
|
pass
|
151
151
|
|
152
|
-
@property
|
153
|
-
@abc.abstractmethod
|
154
|
-
def response_fixtures(self):
|
155
|
-
pass
|
156
|
-
|
157
152
|
@property
|
158
153
|
@abc.abstractmethod
|
159
154
|
def response_fixtures_path(self):
|
stoobly_agent/cli.py
CHANGED
@@ -9,8 +9,10 @@ from stoobly_agent import VERSION
|
|
9
9
|
from stoobly_agent.app.cli.helpers.context import ReplayContext
|
10
10
|
from stoobly_agent.app.cli.helpers.handle_mock_service import print_raw_response, RAW_FORMAT
|
11
11
|
from stoobly_agent.app.cli.helpers.validations import validate_project_key, validate_scenario_key
|
12
|
+
from stoobly_agent.app.cli.intercept_cli import mode_options
|
12
13
|
from stoobly_agent.app.proxy.constants import custom_response_codes
|
13
14
|
from stoobly_agent.app.proxy.replay.replay_request_service import replay as replay_request
|
15
|
+
from stoobly_agent.app.settings.constants import intercept_mode
|
14
16
|
from stoobly_agent.config.constants import env_vars, mode
|
15
17
|
from stoobly_agent.config.data_dir import DataDir
|
16
18
|
from stoobly_agent.lib.logger import Logger
|
@@ -80,7 +82,7 @@ def init(**kwargs):
|
|
80
82
|
help="Run proxy and/or UI",
|
81
83
|
)
|
82
84
|
@ConditionalDecorator(lambda f: click.option('--api-url', help='API URL.')(f), is_remote)
|
83
|
-
@click.option('--ca-certs-dir-path', default=
|
85
|
+
@click.option('--ca-certs-dir-path', default=os.path.join(os.path.expanduser('~'), '.mitmproxy'), help='Path to ca certs directory used to sign SSL certs.')
|
84
86
|
@click.option('--certs', help='''
|
85
87
|
SSL certificates of the form "[domain=]path". The domain may include a wildcard, and is equal to "*" if not specified. The file at path is a certificate in PEM format. If a private key is included in the
|
86
88
|
PEM, it is used, else the default key in the conf dir is used. The PEM file should contain the full certificate chain, with the leaf certificate as the first entry. May be passed multiple times.
|
@@ -90,6 +92,7 @@ def init(**kwargs):
|
|
90
92
|
config.yaml to avoid this.
|
91
93
|
''')
|
92
94
|
@click.option('--connection-strategy', help=', '.join(CONNECTION_STRATEGIES), type=click.Choice(CONNECTION_STRATEGIES))
|
95
|
+
@click.option('--detached', type=click.Path(), help='Run in detached mode and redirect output to the specified file path.')
|
93
96
|
@click.option('--flow-detail', default='1', type=click.Choice(['0', '1', '2', '3', '4']), help='''
|
94
97
|
The display detail level for flows in mitmdump: 0 (quiet) to 4 (very verbose).
|
95
98
|
0: no output
|
@@ -100,7 +103,7 @@ def init(**kwargs):
|
|
100
103
|
''')
|
101
104
|
@click.option('--headless', is_flag=True, default=False, help='Disable starting UI.')
|
102
105
|
@click.option('--intercept', is_flag=True, default=False, help='Enable intercept on run.')
|
103
|
-
@click.option('--intercept-mode', help='Set intercept mode.')
|
106
|
+
@click.option('--intercept-mode', type=click.Choice(mode_options), help='Set intercept mode.')
|
104
107
|
@click.option('--log-level', default=logger.INFO, type=click.Choice([logger.DEBUG, logger.INFO, logger.WARNING, logger.ERROR]), help='''
|
105
108
|
Log levels can be "debug", "info", "warning", or "error"
|
106
109
|
''')
|
@@ -118,8 +121,8 @@ def init(**kwargs):
|
|
118
121
|
the form of "http[s]://host[:port]".
|
119
122
|
''')
|
120
123
|
@click.option('--proxy-port', default=8080, type=click.IntRange(1, 65535), help='Proxy service port.')
|
121
|
-
@click.option('--public-directory-path', help='Path to public files. Used for mocking requests.')
|
122
|
-
@click.option('--response-fixtures-path', help='Path to response fixtures yaml. Used for mocking requests.')
|
124
|
+
@click.option('--public-directory-path', multiple=True, help='Path to public files. Used for mocking requests. Can take the form <FOLDER-PATH>[:<ORIGIN>].')
|
125
|
+
@click.option('--response-fixtures-path', multiple=True, help='Path to response fixtures yaml. Used for mocking requests. Can take the form <FILE-PATH>[:<ORIGIN>].')
|
123
126
|
@click.option('--ssl-insecure', is_flag=True, default=False, help='Do not verify upstream server SSL/TLS certificates.')
|
124
127
|
@click.option('--ui-host', default='0.0.0.0', help='Address to bind UI to.')
|
125
128
|
@click.option('--ui-port', default=4200, type=click.IntRange(1, 65535), help='UI service port.')
|
@@ -130,6 +133,9 @@ def run(**kwargs):
|
|
130
133
|
settings: Settings = Settings.instance()
|
131
134
|
settings.watch()
|
132
135
|
|
136
|
+
if not os.path.exists(kwargs.get('ca_certs_dir_path')):
|
137
|
+
kwargs['ca_certs_dir_path'] = DataDir.instance().ca_certs_dir_path
|
138
|
+
|
133
139
|
if kwargs.get('headless'):
|
134
140
|
os.environ[env_vars.AGENT_HEADLESS] = '1'
|
135
141
|
|
@@ -144,10 +150,18 @@ def run(**kwargs):
|
|
144
150
|
os.environ[env_vars.AGENT_LIFECYCLE_HOOKS_PATH] = kwargs['lifecycle_hooks_path']
|
145
151
|
|
146
152
|
if kwargs.get('public_directory_path'):
|
147
|
-
|
153
|
+
# Join multiple paths with commas
|
154
|
+
public_dirs = kwargs['public_directory_path']
|
155
|
+
if isinstance(public_dirs, (list, tuple)):
|
156
|
+
os.environ[env_vars.AGENT_PUBLIC_DIRECTORY_PATH] = ','.join(public_dirs)
|
157
|
+
else:
|
158
|
+
os.environ[env_vars.AGENT_PUBLIC_DIRECTORY_PATH] = public_dirs
|
148
159
|
|
149
160
|
if kwargs.get('response_fixtures_path'):
|
150
|
-
|
161
|
+
response_fixtures_paths = kwargs.get('response_fixtures_path', ())
|
162
|
+
if response_fixtures_paths:
|
163
|
+
response_fixtures = ','.join(response_fixtures_paths)
|
164
|
+
os.environ[env_vars.AGENT_RESPONSE_FIXTURES_PATH] = response_fixtures
|
151
165
|
|
152
166
|
if not os.getenv(env_vars.LOG_LEVEL):
|
153
167
|
os.environ[env_vars.LOG_LEVEL] = kwargs['log_level']
|
@@ -166,15 +180,46 @@ def run(**kwargs):
|
|
166
180
|
os.environ[env_vars.AGENT_PROXY_URL] = proxy_url
|
167
181
|
settings.proxy.url = proxy_url
|
168
182
|
|
169
|
-
if
|
170
|
-
|
171
|
-
|
183
|
+
if kwargs.get('detached'):
|
184
|
+
# Run in detached mode with output redirection
|
185
|
+
import subprocess
|
186
|
+
import sys
|
187
|
+
|
188
|
+
# Build the command to run in background
|
189
|
+
cmd = [sys.executable, '-m', 'stoobly_agent'] + sys.argv[1:]
|
190
|
+
# Remove the --detached flag and its value from the command
|
191
|
+
detached_index = None
|
192
|
+
for i, arg in enumerate(cmd):
|
193
|
+
if arg == '--detached':
|
194
|
+
detached_index = i
|
195
|
+
break
|
196
|
+
if detached_index is not None:
|
197
|
+
cmd.pop(detached_index) # Remove --detached
|
198
|
+
if detached_index < len(cmd) and not cmd[detached_index].startswith('--'):
|
199
|
+
cmd.pop(detached_index) # Remove the file path
|
200
|
+
|
201
|
+
# Start the process in background with output redirection
|
202
|
+
with open(kwargs['detached'], 'w') as output_file:
|
203
|
+
process = subprocess.Popen(
|
204
|
+
cmd,
|
205
|
+
stdout=output_file,
|
206
|
+
stderr=subprocess.STDOUT, # Redirect stderr to stdout
|
207
|
+
preexec_fn=os.setsid # Create new process group
|
208
|
+
)
|
209
|
+
|
210
|
+
print(process.pid)
|
211
|
+
return
|
212
|
+
else:
|
213
|
+
# Run in foreground mode
|
214
|
+
if not kwargs.get('headless'):
|
215
|
+
settings.commit()
|
216
|
+
run_api(**kwargs)
|
172
217
|
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
218
|
+
if not kwargs.get('proxyless'):
|
219
|
+
log_id = 'Proxy'
|
220
|
+
Logger.instance(log_id).info(f"starting with mode {kwargs['proxy_mode']} and listening at {kwargs['proxy_host']}:{kwargs['proxy_port']}")
|
221
|
+
Logger.instance(log_id).info(f"{'' if settings.proxy.intercept.active else 'not yet '}configured to {settings.proxy.intercept.mode}")
|
222
|
+
run_proxy(**kwargs)
|
178
223
|
|
179
224
|
@main.command(
|
180
225
|
help="Mock request"
|
@@ -186,8 +231,8 @@ def run(**kwargs):
|
|
186
231
|
@click.option('--lifecycle-hooks-path', help='Path to lifecycle hooks script.')
|
187
232
|
@click.option('-o', '--output', help='Write to file instead of stdout')
|
188
233
|
@ConditionalDecorator(lambda f: click.option('--project-key')(f), is_remote)
|
189
|
-
@click.option('--public-directory-path', help='Path to public files. Used for mocking requests.')
|
190
|
-
@click.option('--response-fixtures-path', help='Path to response fixtures yaml. Used for mocking requests.')
|
234
|
+
@click.option('--public-directory-path', multiple=True, help='Path to public files. Used for mocking requests. Can take the form <FOLDER-PATH>[:<ORIGIN>].')
|
235
|
+
@click.option('--response-fixtures-path', multiple=True, help='Path to response fixtures yaml. Used for mocking requests. Can take the form <FILE-PATH>[:<ORIGIN>].')
|
191
236
|
@click.option('-X', '--request', default='GET', help='Specify request command to use')
|
192
237
|
@click.option('--scenario-key')
|
193
238
|
@click.argument('url')
|
stoobly_agent/config/data_dir.py
CHANGED
@@ -56,14 +56,6 @@ class DataDir:
|
|
56
56
|
|
57
57
|
@property
|
58
58
|
def path(self):
|
59
|
-
if not self.__path and os.environ.get(ENV) == 'test':
|
60
|
-
test_path = os.path.join(self.__data_dir_path, TMP_DIR_NAME, DATA_DIR_NAME)
|
61
|
-
|
62
|
-
if not os.path.exists(test_path):
|
63
|
-
os.makedirs(test_path, exist_ok=True)
|
64
|
-
|
65
|
-
return test_path
|
66
|
-
|
67
59
|
return self.__data_dir_path
|
68
60
|
|
69
61
|
@property
|