stoobly-agent 1.10.0__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/api/application_http_request_handler.py +5 -2
- 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 +9 -4
- stoobly_agent/app/cli/scaffold/docker/constants.py +3 -1
- stoobly_agent/app/cli/scaffold/docker/service/build_decorator.py +4 -4
- stoobly_agent/app/cli/scaffold/docker/service/builder.py +31 -54
- 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 +30 -47
- stoobly_agent/app/cli/scaffold/docker/workflow/command_decorator.py +3 -2
- 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 +9 -25
- stoobly_agent/app/cli/scaffold/service_create_command.py +18 -6
- stoobly_agent/app/cli/scaffold/service_docker_compose.py +3 -3
- stoobly_agent/app/cli/scaffold/service_workflow_validate_command.py +10 -7
- stoobly_agent/app/cli/scaffold/templates/app/.Makefile +2 -2
- stoobly_agent/app/cli/scaffold/templates/app/build/.docker-compose.base.yml +4 -4
- stoobly_agent/app/cli/scaffold/templates/app/build/mock/configure +3 -0
- stoobly_agent/app/cli/scaffold/templates/app/build/record/configure +28 -0
- stoobly_agent/app/cli/scaffold/templates/app/build/test/configure +3 -0
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/.docker-compose.base.yml +4 -4
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/configure +3 -0
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/run +3 -0
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/configure +3 -0
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/run +3 -0
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/configure +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 +25 -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/configure +5 -0
- stoobly_agent/app/cli/scaffold/templates/workflow/mock/run +3 -0
- stoobly_agent/app/cli/scaffold/templates/workflow/record/configure +21 -0
- stoobly_agent/app/cli/scaffold/templates/workflow/record/run +3 -0
- stoobly_agent/app/cli/scaffold/templates/workflow/test/configure +5 -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 +134 -182
- 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 +6 -26
- 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/record/upload_request_service.py +3 -6
- 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/app/proxy/utils/publish_change_service.py +20 -23
- stoobly_agent/app/settings/__init__.py +10 -7
- stoobly_agent/cli.py +61 -16
- stoobly_agent/config/data_dir.py +1 -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/main-es2015.5a9aa16433404c3f423a.js +1 -0
- stoobly_agent/public/main-es5.5a9aa16433404c3f423a.js +1 -0
- 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} +4 -11
- stoobly_agent/test/app/cli/scaffold/{e2e_test.py → docker/e2e_test.py} +42 -27
- 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.0.dist-info → stoobly_agent-1.10.2.dist-info}/METADATA +4 -2
- {stoobly_agent-1.10.0.dist-info → stoobly_agent-1.10.2.dist-info}/RECORD +157 -129
- {stoobly_agent-1.10.0.dist-info → stoobly_agent-1.10.2.dist-info}/WHEEL +1 -1
- stoobly_agent/app/cli/helpers/shell.py +0 -26
- stoobly_agent/app/cli/scaffold/templates/app/build/mock/bin/configure +0 -3
- stoobly_agent/app/cli/scaffold/templates/app/build/record/bin/configure +0 -3
- stoobly_agent/app/cli/scaffold/templates/app/build/test/bin/configure +0 -3
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/bin/configure +0 -3
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/bin/configure +0 -3
- stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/bin/configure +0 -3
- stoobly_agent/app/cli/scaffold/templates/workflow/mock/bin/configure +0 -13
- stoobly_agent/app/cli/scaffold/templates/workflow/record/bin/configure +0 -47
- stoobly_agent/app/cli/scaffold/templates/workflow/test/bin/configure +0 -13
- stoobly_agent/public/12-es2015.be58ed0ef449008b932e.js +0 -1
- stoobly_agent/public/12-es5.be58ed0ef449008b932e.js +0 -1
- stoobly_agent/public/main-es2015.089b46f303768fbe864f.js +0 -1
- stoobly_agent/public/main-es5.089b46f303768fbe864f.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/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/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/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/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/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/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/init → init} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/workflow/record/{bin/init → init} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/workflow/test/{bin/init → init} +0 -0
- {stoobly_agent-1.10.0.dist-info → stoobly_agent-1.10.2.dist-info}/entry_points.txt +0 -0
- {stoobly_agent-1.10.0.dist-info → stoobly_agent-1.10.2.dist-info/licenses}/LICENSE +0 -0
@@ -38,9 +38,6 @@ class InterceptSettings:
|
|
38
38
|
self.__lifecycle_hooks = None
|
39
39
|
self.__initialize_lifecycle_hooks()
|
40
40
|
|
41
|
-
self.__response_fixtures = None
|
42
|
-
self.__initialize_response_fixtures()
|
43
|
-
|
44
41
|
self._mock_rewrite_rules = None
|
45
42
|
self._record_rewrite_rules = None
|
46
43
|
self._replay_rewrite_rules = None
|
@@ -110,11 +107,12 @@ class InterceptSettings:
|
|
110
107
|
|
111
108
|
@property
|
112
109
|
def public_directory_path(self):
|
110
|
+
"""Get raw public directory paths string from environment or headers."""
|
113
111
|
if self.__headers and custom_headers.PUBLIC_DIRECTORY_PATH in self.__headers:
|
114
112
|
return self.__headers[custom_headers.PUBLIC_DIRECTORY_PATH]
|
115
|
-
|
116
|
-
|
117
|
-
|
113
|
+
elif os.environ.get(env_vars.AGENT_PUBLIC_DIRECTORY_PATH):
|
114
|
+
return os.environ[env_vars.AGENT_PUBLIC_DIRECTORY_PATH]
|
115
|
+
return None
|
118
116
|
|
119
117
|
@property
|
120
118
|
def remote_project_key(self):
|
@@ -130,16 +128,13 @@ class InterceptSettings:
|
|
130
128
|
|
131
129
|
@property
|
132
130
|
def response_fixtures_path(self):
|
131
|
+
"""Returns comma-separated list of response fixtures paths, optionally with origin prefix."""
|
133
132
|
if self.__headers and custom_headers.RESPONSE_FIXTURES_PATH in self.__headers:
|
134
133
|
return self.__headers[custom_headers.RESPONSE_FIXTURES_PATH]
|
135
134
|
|
136
135
|
if os.environ.get(env_vars.AGENT_RESPONSE_FIXTURES_PATH):
|
137
136
|
return os.environ[env_vars.AGENT_RESPONSE_FIXTURES_PATH]
|
138
137
|
|
139
|
-
@property
|
140
|
-
def response_fixtures(self):
|
141
|
-
return self.__response_fixtures or {}
|
142
|
-
|
143
138
|
@property
|
144
139
|
def parsed_remote_project_key(self):
|
145
140
|
try:
|
@@ -280,7 +275,7 @@ class InterceptSettings:
|
|
280
275
|
if self.__headers and custom_headers.REQUEST_ORIGIN in self.__headers:
|
281
276
|
return self.__headers[custom_headers.REQUEST_ORIGIN]
|
282
277
|
|
283
|
-
return request_origin.
|
278
|
+
return request_origin.PROXY
|
284
279
|
|
285
280
|
def for_response(self):
|
286
281
|
self.__for_response = True
|
@@ -333,21 +328,6 @@ class InterceptSettings:
|
|
333
328
|
except Exception as e:
|
334
329
|
return Logger.instance().error(e)
|
335
330
|
|
336
|
-
def __initialize_response_fixtures(self):
|
337
|
-
fixtures_path = self.response_fixtures_path
|
338
|
-
|
339
|
-
if not fixtures_path:
|
340
|
-
return
|
341
|
-
|
342
|
-
if not os.path.exists(fixtures_path):
|
343
|
-
return Logger.instance().error(f"Response fixtures {fixtures_path} does not exist")
|
344
|
-
|
345
|
-
with open(fixtures_path, 'r') as stream:
|
346
|
-
try:
|
347
|
-
self.__response_fixtures = yaml.safe_load(stream)
|
348
|
-
except yaml.YAMLError as exc:
|
349
|
-
Logger.instance().error(exc)
|
350
|
-
|
351
331
|
def __order(self, mode):
|
352
332
|
if mode == intercept_mode.RECORD:
|
353
333
|
return self.__data_rules.record_order
|
@@ -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
|
@@ -63,10 +63,7 @@ def upload_request(
|
|
63
63
|
scenario_key=scenario_key
|
64
64
|
)
|
65
65
|
|
66
|
-
|
67
|
-
# This means that we have access to Cache singleton and do not need send a request to update the status
|
68
|
-
sync = intercept_settings.request_origin == request_origin.WEB
|
69
|
-
res = __upload_request_with_body_params(request_model, body_params, sync)
|
66
|
+
res = __upload_request_with_body_params(request_model, body_params)
|
70
67
|
|
71
68
|
if intercept_settings.settings.is_debug():
|
72
69
|
file_path = __debug_request(flow.request, joined_request.build())
|
@@ -107,11 +104,11 @@ def upload_staged_request(
|
|
107
104
|
|
108
105
|
return __upload_request_with_body_params(request_model, body_params)
|
109
106
|
|
110
|
-
def __upload_request_with_body_params(request_model: RequestModel, body_params: dict
|
107
|
+
def __upload_request_with_body_params(request_model: RequestModel, body_params: dict):
|
111
108
|
request, status = request_model.create(**body_params)
|
112
109
|
|
113
110
|
if status < 400:
|
114
|
-
publish_requests_modified(body_params['project_id']
|
111
|
+
publish_requests_modified(body_params['project_id'])
|
115
112
|
|
116
113
|
return request
|
117
114
|
|
@@ -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):
|
@@ -1,41 +1,35 @@
|
|
1
|
-
import os
|
2
1
|
import pdb
|
2
|
+
import requests
|
3
3
|
import threading
|
4
4
|
|
5
|
-
from typing import TypedDict
|
6
|
-
|
7
5
|
from stoobly_agent.app.settings import Settings
|
8
|
-
from stoobly_agent.config.constants.statuses import REQUESTS_MODIFIED
|
6
|
+
from stoobly_agent.config.constants.statuses import REQUESTS_MODIFIED, SETTINGS_MODIFIED
|
9
7
|
from stoobly_agent.lib.api.agent_api import AgentApi
|
10
8
|
from stoobly_agent.lib.cache import Cache
|
11
9
|
from stoobly_agent.lib.logger import Logger
|
12
10
|
|
13
|
-
|
14
|
-
sync: bool
|
15
|
-
|
16
|
-
# Announce that a new request has been created
|
17
|
-
def publish_change(status: str, value, **options: Options):
|
18
|
-
if options.get('sync'):
|
19
|
-
return __publish_change_sync(status, value)
|
11
|
+
LOG_ID = 'PublishChange'
|
20
12
|
|
13
|
+
def publish_settings_modified(value):
|
14
|
+
return __publish_change_sync(SETTINGS_MODIFIED, value)
|
15
|
+
|
16
|
+
def publish_requests_modified(value):
|
21
17
|
settings: Settings = Settings.instance()
|
22
18
|
|
23
|
-
# If
|
24
|
-
if
|
25
|
-
return
|
19
|
+
# If not headless...
|
20
|
+
if settings.ui.active:
|
21
|
+
return __publish_change_sync(REQUESTS_MODIFIED, value)
|
26
22
|
|
27
23
|
ui_url = settings.ui.url
|
28
|
-
|
29
24
|
if not ui_url:
|
30
|
-
Logger.instance().warn('Settings.ui.url not configured')
|
31
25
|
return False
|
32
|
-
else:
|
33
|
-
thread = threading.Thread(target=__put_status, args=(ui_url, status, value))
|
34
|
-
thread.start()
|
35
|
-
return True
|
36
26
|
|
37
|
-
|
38
|
-
|
27
|
+
return __publish_change_async(REQUESTS_MODIFIED, value, ui_url)
|
28
|
+
|
29
|
+
def __publish_change_async(status, value, ui_url: str):
|
30
|
+
thread = threading.Thread(target=__put_status, args=(ui_url, status, value))
|
31
|
+
thread.start()
|
32
|
+
return True
|
39
33
|
|
40
34
|
def __publish_change_sync(status: str, value):
|
41
35
|
cache = Cache.instance()
|
@@ -44,4 +38,7 @@ def __publish_change_sync(status: str, value):
|
|
44
38
|
|
45
39
|
def __put_status(ui_url, status, value):
|
46
40
|
api: AgentApi = AgentApi(ui_url)
|
47
|
-
|
41
|
+
try:
|
42
|
+
api.update_status(status, value)
|
43
|
+
except requests.exceptions.ConnectionError:
|
44
|
+
Logger.instance(LOG_ID).error(f"could not connect to {ui_url}")
|
@@ -8,7 +8,7 @@ from watchdog.observers import Observer
|
|
8
8
|
from watchdog.events import PatternMatchingEventHandler
|
9
9
|
from yamale import *
|
10
10
|
|
11
|
-
from stoobly_agent.config.constants import env_vars
|
11
|
+
from stoobly_agent.config.constants import env_vars
|
12
12
|
from stoobly_agent.config.data_dir import DataDir
|
13
13
|
from stoobly_agent.config.source_dir import SourceDir
|
14
14
|
from stoobly_agent.lib.logger import Logger
|
@@ -19,6 +19,7 @@ from .remote_settings import RemoteSettings
|
|
19
19
|
from .ui_settings import UISettings
|
20
20
|
|
21
21
|
LOG_ID = 'Settings'
|
22
|
+
SETTINGS_YML = 'settings.yml'
|
22
23
|
|
23
24
|
class Settings:
|
24
25
|
_instances = None
|
@@ -102,7 +103,7 @@ class Settings:
|
|
102
103
|
if self.__watching:
|
103
104
|
return False
|
104
105
|
|
105
|
-
patterns = [
|
106
|
+
patterns = [SETTINGS_YML]
|
106
107
|
ignore_patterns = None
|
107
108
|
ignore_directories = False
|
108
109
|
case_sensitive = True
|
@@ -180,11 +181,12 @@ class Settings:
|
|
180
181
|
if not contents:
|
181
182
|
return
|
182
183
|
|
183
|
-
|
184
|
-
|
184
|
+
lock_file = f".{SETTINGS_YML}.lock"
|
185
|
+
lock_file_path = os.path.join(os.path.dirname(self.__settings_file_path), lock_file)
|
186
|
+
lock = FileLock(lock_file_path) # lock file alongside the target
|
185
187
|
|
186
188
|
with lock:
|
187
|
-
with open(
|
189
|
+
with open(self.__settings_file_path, 'w') as fp:
|
188
190
|
yaml.dump(contents, fp, allow_unicode=True)
|
189
191
|
|
190
192
|
### Helpers
|
@@ -215,13 +217,14 @@ class Settings:
|
|
215
217
|
|
216
218
|
def __reload_settings(self, event):
|
217
219
|
if not self.__load_lock:
|
218
|
-
from stoobly_agent.app.proxy.utils.publish_change_service import
|
220
|
+
from stoobly_agent.app.proxy.utils.publish_change_service import publish_settings_modified
|
219
221
|
|
220
222
|
self.__load_lock = True
|
221
223
|
|
222
224
|
Logger.instance(LOG_ID).debug('Reloading settings')
|
223
225
|
self.__load_settings()
|
224
226
|
|
225
|
-
|
227
|
+
if self.__ui_settings.active:
|
228
|
+
publish_settings_modified(self.__settings)
|
226
229
|
|
227
230
|
self.__load_lock = False
|