stoobly-agent 1.4.2__py3-none-any.whl → 1.5.1__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/app/cli/helpers/handle_config_update_service.py +2 -2
- stoobly_agent/app/cli/helpers/handle_mock_service.py +6 -2
- stoobly_agent/app/cli/helpers/request_facade.py +5 -1
- stoobly_agent/app/cli/scaffold/constants.py +1 -1
- stoobly_agent/app/cli/scaffold/docker/workflow/mock_decorator.py +1 -0
- stoobly_agent/app/cli/scaffold/service_workflow_validate_command.py +19 -19
- stoobly_agent/app/cli/scaffold/templates/app/.Dockerfile.context +1 -1
- stoobly_agent/app/cli/scaffold/templates/constants.py +3 -3
- stoobly_agent/app/cli/scaffold/templates/factory.py +5 -5
- stoobly_agent/app/cli/scaffold/templates/workflow/mock/bin/configure +1 -8
- stoobly_agent/app/cli/scaffold/templates/workflow/mock/fixtures.yml +1 -1
- stoobly_agent/app/cli/scaffold/templates/workflow/test/bin/configure +1 -8
- stoobly_agent/app/cli/scaffold/templates/workflow/test/fixtures.yml +1 -1
- stoobly_agent/app/cli/scaffold/workflow_command.py +3 -3
- stoobly_agent/app/cli/scaffold/workflow_create_command.py +2 -2
- stoobly_agent/app/cli/scaffold_cli.py +5 -5
- stoobly_agent/app/models/factories/resource/local_db/helpers/tiebreak_scenario_request.py +1 -1
- stoobly_agent/app/models/factories/resource/local_db/request_adapter.py +17 -11
- stoobly_agent/app/models/types/request.py +1 -2
- stoobly_agent/app/proxy/context.py +4 -0
- stoobly_agent/app/proxy/handle_mock_service.py +93 -46
- stoobly_agent/app/proxy/handle_record_service.py +15 -3
- stoobly_agent/app/proxy/handle_replay_service.py +44 -18
- stoobly_agent/app/proxy/handle_test_service.py +92 -24
- stoobly_agent/app/proxy/intercept_handler.py +11 -16
- stoobly_agent/app/proxy/intercept_settings.py +17 -4
- stoobly_agent/app/proxy/mitmproxy/request_facade.py +5 -2
- stoobly_agent/app/proxy/mitmproxy/response_facade.py +5 -4
- stoobly_agent/app/proxy/mock/custom_not_found_response_builder.py +5 -0
- stoobly_agent/app/proxy/mock/eval_fixtures_service.py +79 -14
- stoobly_agent/app/proxy/mock/eval_request_service.py +18 -13
- stoobly_agent/app/proxy/record/join_request_service.py +7 -8
- stoobly_agent/app/proxy/record/upload_request_service.py +2 -2
- stoobly_agent/app/proxy/replay/replay_request_service.py +4 -4
- stoobly_agent/app/proxy/test/helpers/upload_test_service.py +2 -2
- stoobly_agent/app/proxy/utils/allowed_request_service.py +3 -3
- stoobly_agent/app/proxy/utils/response_handler.py +10 -1
- stoobly_agent/app/proxy/utils/rewrite.py +72 -0
- stoobly_agent/app/settings/constants/request_component.py +4 -1
- stoobly_agent/cli.py +35 -28
- stoobly_agent/config/constants/custom_headers.py +1 -0
- stoobly_agent/config/constants/intercept_policy.py +2 -0
- stoobly_agent/config/constants/mock_policy.py +4 -2
- stoobly_agent/config/constants/query_params.py +2 -0
- stoobly_agent/config/constants/record_policy.py +4 -2
- stoobly_agent/config/constants/replay_policy.py +4 -2
- stoobly_agent/public/{18-es2015.583f191cc7ad512ee262.js → 18-es2015.503207073756a9c8211a.js} +1 -1
- stoobly_agent/public/{18-es5.583f191cc7ad512ee262.js → 18-es5.503207073756a9c8211a.js} +1 -1
- stoobly_agent/public/index.html +1 -1
- stoobly_agent/public/{main-es2015.2cc16523aa3fcaba51e5.js → main-es2015.d682619f3d6d53d64c6a.js} +1 -1
- stoobly_agent/public/{main-es5.2cc16523aa3fcaba51e5.js → main-es5.d682619f3d6d53d64c6a.js} +1 -1
- stoobly_agent/public/{runtime-es2015.b914470164e4d6e75d96.js → runtime-es2015.8c1efed946fc02c923fc.js} +1 -1
- stoobly_agent/public/{runtime-es5.b914470164e4d6e75d96.js → runtime-es5.8c1efed946fc02c923fc.js} +1 -1
- stoobly_agent/test/app/cli/helpers/openapi_endpoint_adapter_test.py +2 -1
- stoobly_agent/test/app/cli/scaffold/e2e_test.py +2 -2
- stoobly_agent/test/app/models/factories/resource/local_db/helpers/tiebreak_scenario_request_test.py +4 -4
- stoobly_agent/test/app/models/schemas/.stoobly/db/VERSION +1 -1
- stoobly_agent/test/app/proxy/mock/eval_fixtures_service_test.py +140 -71
- stoobly_agent/test/cli/lifecycle_hooks_test.py +66 -0
- stoobly_agent/test/cli/mock_scenario_lifecycle_hooks.py +5 -0
- stoobly_agent/test/cli/mock_scenario_test.py +62 -0
- stoobly_agent/test/cli/mock_test.py +54 -38
- stoobly_agent/test/cli/record_test.py +67 -0
- stoobly_agent/test/mock_data/lifecycle_hooks.py +35 -0
- {stoobly_agent-1.4.2.dist-info → stoobly_agent-1.5.1.dist-info}/LICENSE +1 -1
- {stoobly_agent-1.4.2.dist-info → stoobly_agent-1.5.1.dist-info}/METADATA +7 -12
- {stoobly_agent-1.4.2.dist-info → stoobly_agent-1.5.1.dist-info}/RECORD +72 -65
- /stoobly_agent/app/cli/scaffold/templates/workflow/mock/{fixtures/.keep → public/.gitignore} +0 -0
- /stoobly_agent/app/cli/scaffold/templates/workflow/test/{fixtures/.keep → public/.gitignore} +0 -0
- {stoobly_agent-1.4.2.dist-info → stoobly_agent-1.5.1.dist-info}/WHEEL +0 -0
- {stoobly_agent-1.4.2.dist-info → stoobly_agent-1.5.1.dist-info}/entry_points.txt +0 -0
stoobly_agent/__init__.py
CHANGED
@@ -1,2 +1,2 @@
|
|
1
1
|
COMMAND = 'stoobly-agent'
|
2
|
-
VERSION = '1.
|
2
|
+
VERSION = '1.5.1'
|
@@ -58,9 +58,9 @@ def handle_intercept_active_update(new_settings: Settings, context: Context = No
|
|
58
58
|
scenario_model.update(_scenario_key.id, **{ 'overwritable': False })[1]
|
59
59
|
elif _mode == intercept_mode.MOCK:
|
60
60
|
# When mock is stopped, clear request access counts
|
61
|
-
from stoobly_agent.app.models.factories.resource.local_db.helpers.tiebreak_scenario_request import
|
61
|
+
from stoobly_agent.app.models.factories.resource.local_db.helpers.tiebreak_scenario_request import reset_sessions
|
62
62
|
|
63
|
-
|
63
|
+
reset_sessions()
|
64
64
|
|
65
65
|
def handle_scenario_update(new_settings: Settings, context = None):
|
66
66
|
new_scenario_key = __scenario_key(new_settings.proxy)
|
@@ -6,9 +6,13 @@ from stoobly_agent.app.proxy.record.response_string import ResponseString
|
|
6
6
|
|
7
7
|
RAW_FORMAT = 'raw'
|
8
8
|
|
9
|
-
def print_raw_response(response: requests.Response):
|
9
|
+
def print_raw_response(response: requests.Response, file_path = None):
|
10
10
|
mitmproxy_response = PythonResponseAdapterFactory(response).mitmproxy_response()
|
11
11
|
facade = MitmproxyResponseFacade(mitmproxy_response)
|
12
12
|
response_string = ResponseString(facade, None)
|
13
13
|
|
14
|
-
|
14
|
+
if not file_path:
|
15
|
+
print(response_string.get().decode(), end="")
|
16
|
+
else:
|
17
|
+
with open(file_path, 'w') as fp:
|
18
|
+
fp.write(response_string.get().decode())
|
@@ -53,10 +53,14 @@ class RequestFacade(ReplayFacade):
|
|
53
53
|
def replay(self, request_key: str, cli_options: ReplayCliOptions):
|
54
54
|
replay_context = self.__build_replay_context(request_key)
|
55
55
|
replay_options = {
|
56
|
-
'mode': mode.
|
56
|
+
'mode': mode.REPLAY,
|
57
57
|
**self.__common_replay_options(request_key),
|
58
58
|
**self.common_replay_cli_options(cli_options)
|
59
59
|
}
|
60
|
+
|
61
|
+
if cli_options.get('record'):
|
62
|
+
replay_options['response_mode'] = mode.RECORD
|
63
|
+
|
60
64
|
trace_context = replay_options.get('trace_context')
|
61
65
|
|
62
66
|
return self.__replay(replay_context, trace_context, replay_options)
|
@@ -14,8 +14,8 @@ CONFIG_FILE = '.config.yml'
|
|
14
14
|
CONTEXT_DIR_ENV = 'CONTEXT_DIR'
|
15
15
|
DOCKER_NAMESPACE = 'docker'
|
16
16
|
ENV_FILE = '.env'
|
17
|
-
FIXTURES_FOLDER_NAME = 'fixtures'
|
18
17
|
NAMESERVERS_FILE = '.nameservers'
|
18
|
+
PUBLIC_FOLDER_NAME = 'public'
|
19
19
|
SERVICE_DETACHED = '${SERVICE_DETACHED}'
|
20
20
|
SERVICE_DETACHED_ENV = 'SERVICE_DETACHED'
|
21
21
|
SERVICE_HOSTNAME = '${SERVICE_HOSTNAME}'
|
@@ -26,6 +26,7 @@ class MockDecorator():
|
|
26
26
|
'--lifecycle-hooks-path', 'lifecycle_hooks.py',
|
27
27
|
'--proxy-mode', SERVICE_PROXY_MODE,
|
28
28
|
'--proxy-port', f"{SERVICE_PORT}",
|
29
|
+
'--public-directory-path', 'public',
|
29
30
|
'--response-fixtures-path', 'fixtures.yml',
|
30
31
|
'--ssl-insecure'
|
31
32
|
]
|
@@ -10,7 +10,7 @@ import yaml
|
|
10
10
|
from docker.models.containers import Container
|
11
11
|
|
12
12
|
from stoobly_agent.app.cli.scaffold.constants import (
|
13
|
-
|
13
|
+
PUBLIC_FOLDER_NAME,
|
14
14
|
STOOBLY_DATA_DIR,
|
15
15
|
VIRTUAL_HOST_ENV,
|
16
16
|
VIRTUAL_PORT_ENV,
|
@@ -40,8 +40,8 @@ class ServiceWorkflowValidateCommand(ServiceCommand, ValidateCommand):
|
|
40
40
|
)
|
41
41
|
|
42
42
|
@property
|
43
|
-
def
|
44
|
-
return os.path.join(self.workflow_path,
|
43
|
+
def public_dir_path(self):
|
44
|
+
return os.path.join(self.workflow_path, PUBLIC_FOLDER_NAME)
|
45
45
|
|
46
46
|
@property
|
47
47
|
def workflow_path(self):
|
@@ -124,14 +124,14 @@ class ServiceWorkflowValidateCommand(ServiceCommand, ValidateCommand):
|
|
124
124
|
if ('200 OK' not in logs) and ('499' not in logs):
|
125
125
|
raise ScaffoldValidateException(f"Error reaching {url} from inside Docker network")
|
126
126
|
|
127
|
-
# Check
|
128
|
-
def
|
127
|
+
# Check public folder exists in container
|
128
|
+
def validate_public_folder(self, container: Container):
|
129
129
|
|
130
130
|
if self.workflow_name == WORKFLOW_RECORD_TYPE:
|
131
|
-
print(f"Skipping validating
|
131
|
+
print(f"Skipping validating public folder in workflow: {self.workflow_name}, container: {container.name}")
|
132
132
|
return
|
133
133
|
|
134
|
-
print(f"Validating
|
134
|
+
print(f"Validating public folder in container: {container.name}")
|
135
135
|
|
136
136
|
data_dir_mounted = False
|
137
137
|
volume_mounts = container.attrs['Mounts']
|
@@ -145,21 +145,21 @@ class ServiceWorkflowValidateCommand(ServiceCommand, ValidateCommand):
|
|
145
145
|
|
146
146
|
# Only the running proxy containers will be checkable
|
147
147
|
if container.status == 'exited':
|
148
|
-
print(f"Skipping validating
|
148
|
+
print(f"Skipping validating public folder contents because container is exited: {container.name}")
|
149
149
|
return
|
150
150
|
|
151
|
-
# Check contents of
|
152
|
-
|
153
|
-
exec_result = container.exec_run(f"ls -A {
|
151
|
+
# Check contents of public folder to confirm it's shared
|
152
|
+
public_folder_path = f"{PUBLIC_FOLDER_NAME}"
|
153
|
+
exec_result = container.exec_run(f"ls -A {public_folder_path}")
|
154
154
|
output = exec_result.output
|
155
155
|
|
156
|
-
|
157
|
-
if
|
158
|
-
|
159
|
-
|
156
|
+
public_folder_contents_container = output.decode('ascii').split('\n')
|
157
|
+
if public_folder_contents_container[-1] == '':
|
158
|
+
public_folder_contents_container.pop()
|
159
|
+
public_folder_contents_scaffold = os.listdir(self.public_dir_path)
|
160
160
|
|
161
|
-
if Counter(
|
162
|
-
raise ScaffoldValidateException(f"
|
161
|
+
if Counter(public_folder_contents_container) != Counter(public_folder_contents_scaffold):
|
162
|
+
raise ScaffoldValidateException(f"public folder was not mounted properly, expected {self.public_dir_path} to exist in container path {public_folder_path}")
|
163
163
|
|
164
164
|
# Note: might not need this if the hostname is reachable and working
|
165
165
|
def proxy_environment_variables_exist(self, container: Container) -> None:
|
@@ -191,7 +191,7 @@ class ServiceWorkflowValidateCommand(ServiceCommand, ValidateCommand):
|
|
191
191
|
raise ScaffoldValidateException(f"Container attributes are missing for: {container.name}")
|
192
192
|
|
193
193
|
if not self.service_config.detached:
|
194
|
-
self.
|
194
|
+
self.validate_public_folder(service_proxy_container)
|
195
195
|
|
196
196
|
self.proxy_environment_variables_exist(service_proxy_container)
|
197
197
|
|
@@ -215,7 +215,7 @@ class ServiceWorkflowValidateCommand(ServiceCommand, ValidateCommand):
|
|
215
215
|
|
216
216
|
# Service init containers have a mounted dist folder unlike the core init container
|
217
217
|
init_container = self.docker_client.containers.get(self.service_docker_compose.init_container_name)
|
218
|
-
self.
|
218
|
+
self.validate_public_folder(init_container)
|
219
219
|
|
220
220
|
if self.service_config.hostname:
|
221
221
|
service_proxy_container = self.docker_client.containers.get(self.service_docker_compose.proxy_container_name)
|
@@ -18,12 +18,12 @@ CUSTOM_FIXTURES = 'fixtures.yml'
|
|
18
18
|
CUSTOM_LIFECYCLE_HOOKS = os.path.join('lifecycle_hooks.py')
|
19
19
|
MAINTAINED_CONFIGURE = os.path.join('bin', '.configure')
|
20
20
|
MAINTAINED_INIT = os.path.join('bin', '.init')
|
21
|
-
|
21
|
+
MAINTAINED_PUBLIC = os.path.join('public', '.gitignore')
|
22
22
|
|
23
23
|
MOCK_WORKFLOW_MAINTAINED_FILES = [
|
24
24
|
MAINTAINED_CONFIGURE,
|
25
25
|
MAINTAINED_INIT,
|
26
|
-
|
26
|
+
MAINTAINED_PUBLIC
|
27
27
|
]
|
28
28
|
|
29
29
|
MOCK_WORKFLOW_CUSTOM_FILES = [
|
@@ -49,7 +49,7 @@ RECORD_WORKFLOW_CUSTOM_FILES = [
|
|
49
49
|
TEST_WORKFLOW_MAINTAINED_FILES = [
|
50
50
|
MAINTAINED_CONFIGURE,
|
51
51
|
MAINTAINED_INIT,
|
52
|
-
|
52
|
+
MAINTAINED_PUBLIC
|
53
53
|
]
|
54
54
|
|
55
55
|
TEST_WORKFLOW_CUSTOM_FILES = [
|
@@ -1,7 +1,7 @@
|
|
1
1
|
from ..constants import WORKFLOW_MOCK_TYPE, WORKFLOW_RECORD_TYPE, WORKFLOW_TEST_TYPE
|
2
2
|
from ..docker.workflow.builder import WorkflowBuilder
|
3
3
|
from .constants import (
|
4
|
-
CUSTOM_CONFIGURE, CUSTOM_INIT, MAINTAINED_CONFIGURE,
|
4
|
+
CUSTOM_CONFIGURE, CUSTOM_INIT, MAINTAINED_CONFIGURE, MAINTAINED_PUBLIC, MOCK_WORKFLOW_CUSTOM_FILES, MOCK_WORKFLOW_MAINTAINED_FILES, RECORD_WORKFLOW_CUSTOM_FILES, RECORD_WORKFLOW_MAINTAINED_FILES, TEST_WORKFLOW_CUSTOM_FILES, TEST_WORKFLOW_MAINTAINED_FILES
|
5
5
|
)
|
6
6
|
|
7
7
|
def custom_files(workflow: str, workflow_builder: WorkflowBuilder):
|
@@ -21,8 +21,8 @@ def custom_files(workflow: str, workflow_builder: WorkflowBuilder):
|
|
21
21
|
|
22
22
|
# Fixtures are only relevant if the workflow is mock/test and if the service has a hostname
|
23
23
|
if not workflow_builder.config.hostname:
|
24
|
-
if
|
25
|
-
files.remove(
|
24
|
+
if MAINTAINED_PUBLIC in files:
|
25
|
+
files.remove(MAINTAINED_PUBLIC)
|
26
26
|
|
27
27
|
return files
|
28
28
|
|
@@ -40,7 +40,7 @@ def maintained_files(workflow: str, workflow_builder: WorkflowBuilder):
|
|
40
40
|
files.append(MAINTAINED_CONFIGURE)
|
41
41
|
|
42
42
|
if not workflow_builder.config.hostname:
|
43
|
-
if
|
44
|
-
files.remove(
|
43
|
+
if MAINTAINED_PUBLIC in files:
|
44
|
+
files.remove(MAINTAINED_PUBLIC)
|
45
45
|
|
46
46
|
return files
|
@@ -10,11 +10,4 @@ url="$scheme://$hostname"
|
|
10
10
|
|
11
11
|
if [ "$scheme" = 'http' -a "$port" != '80' ] || [ "$scheme" = 'https' -a "$port" != '443' ]; then
|
12
12
|
url="$url:$port"
|
13
|
-
fi
|
14
|
-
|
15
|
-
# Match Rules
|
16
|
-
echo "Configuring match rules"
|
17
|
-
stoobly-agent config match set \
|
18
|
-
--method GET --method POST --method OPTIONS --method PUT --method DELETE \
|
19
|
-
--mode mock \
|
20
|
-
--pattern ".*?"
|
13
|
+
fi
|
@@ -1,5 +1,5 @@
|
|
1
1
|
# The following example matches requests for GET /users/d+ (e.g. /users/1) with the contents of user-1.json
|
2
|
-
# Assumes that 'user-1.json' is created in
|
2
|
+
# Assumes that 'user-1.json' is created in a 'fixtures' folder in the same directory as this file
|
3
3
|
#GET:
|
4
4
|
# /users/d+:
|
5
5
|
# path: ./fixtures/user-1.json
|
@@ -10,11 +10,4 @@ url="$scheme://$hostname"
|
|
10
10
|
|
11
11
|
if [ "$scheme" = 'http' -a "$port" != '80' ] || [ "$scheme" = 'https' -a "$port" != '443' ]; then
|
12
12
|
url="$url:$port"
|
13
|
-
fi
|
14
|
-
|
15
|
-
# Match
|
16
|
-
echo "Configuring match rules"
|
17
|
-
stoobly-agent config match set \
|
18
|
-
--method GET --method POST --method OPTIONS --method PUT --method DELETE \
|
19
|
-
--mode mock \
|
20
|
-
--pattern ".*?"
|
13
|
+
fi
|
@@ -1,5 +1,5 @@
|
|
1
1
|
# The following example matches requests for GET /users/d+ (e.g. /users/1) with the contents of user-1.json
|
2
|
-
# Assumes that 'user-1.json' is created in
|
2
|
+
# Assumes that 'user-1.json' is created in a 'fixtures' folder in the same directory as this file
|
3
3
|
#GET:
|
4
4
|
# /users/d+:
|
5
5
|
# path: ./fixtures/user-1.json
|
@@ -6,7 +6,7 @@ from stoobly_agent.lib.logger import Logger
|
|
6
6
|
|
7
7
|
from .app import App
|
8
8
|
from .config import Config
|
9
|
-
from .constants import BIN_FOLDER_NAME, COMPOSE_TEMPLATE, CONFIG_FILE, ENV_FILE,
|
9
|
+
from .constants import BIN_FOLDER_NAME, COMPOSE_TEMPLATE, CONFIG_FILE, ENV_FILE, PUBLIC_FOLDER_NAME
|
10
10
|
from .docker.constants import DOCKER_COMPOSE_CUSTOM
|
11
11
|
from .service_command import ServiceCommand
|
12
12
|
|
@@ -88,8 +88,8 @@ class WorkflowCommand(ServiceCommand):
|
|
88
88
|
return services
|
89
89
|
|
90
90
|
@property
|
91
|
-
def
|
92
|
-
return os.path.join(self.workflow_path,
|
91
|
+
def public_dir_path(self):
|
92
|
+
return os.path.join(self.workflow_path, PUBLIC_FOLDER_NAME)
|
93
93
|
|
94
94
|
@property
|
95
95
|
def workflow_config(self):
|
@@ -67,11 +67,11 @@ class WorkflowCreateCommand(WorkflowCommand):
|
|
67
67
|
return
|
68
68
|
|
69
69
|
# Maintained files are files that will always be overwritten
|
70
|
-
maintained_workflow_files = maintained_files(self.workflow_name, workflow_builder)
|
70
|
+
maintained_workflow_files = maintained_files(template or self.workflow_name, workflow_builder)
|
71
71
|
self.copy_files(templates_path, maintained_workflow_files, self.workflow_path)
|
72
72
|
|
73
73
|
# Custom files are files that may be modified by the user
|
74
|
-
custom_workflow_files = custom_files(self.workflow_name, workflow_builder)
|
74
|
+
custom_workflow_files = custom_files(template or self.workflow_name, workflow_builder)
|
75
75
|
self.copy_files_no_replace(templates_path, custom_workflow_files, self.workflow_path)
|
76
76
|
|
77
77
|
def __write_docker_compose_file(self, **kwargs: BuildOptions):
|
@@ -140,19 +140,19 @@ def mkcert(**kwargs):
|
|
140
140
|
help="Scaffold a service",
|
141
141
|
)
|
142
142
|
@click.option('--app-dir-path', default=current_working_dir, help='Path to application directory.')
|
143
|
-
@click.option('--detached', is_flag=True)
|
143
|
+
@click.option('--detached', is_flag=True, help='Use isolated and non-persistent context directory.')
|
144
144
|
@click.option('--env', multiple=True, help='Specify an environment variable.')
|
145
145
|
@click.option('--force', is_flag=True, help='Overwrite maintained scaffolded service files.')
|
146
|
-
@click.option('--hostname')
|
147
|
-
@click.option('--port')
|
148
|
-
@click.option('--priority', default=
|
146
|
+
@click.option('--hostname', help='Service hostname.')
|
147
|
+
@click.option('--port', help='Service port.')
|
148
|
+
@click.option('--priority', default=5, type=click.FloatRange(1.0, 9.0), help='Determines the service run order. Lower values run first.')
|
149
149
|
@click.option('--proxy-mode', help='''
|
150
150
|
Proxy mode can be "regular", "transparent", "socks5",
|
151
151
|
"reverse:SPEC", or "upstream:SPEC". For reverse and
|
152
152
|
upstream proxy modes, SPEC is host specification in
|
153
153
|
the form of "http[s]://host[:port]".
|
154
154
|
''')
|
155
|
-
@click.option('--scheme', type=click.Choice(['http', 'https']))
|
155
|
+
@click.option('--scheme', type=click.Choice(['http', 'https']), help='Defaults to https if hostname is set.')
|
156
156
|
@click.option('--workflow', multiple=True, type=click.Choice([WORKFLOW_MOCK_TYPE, WORKFLOW_RECORD_TYPE, WORKFLOW_TEST_TYPE]), help='Include pre-defined workflows.')
|
157
157
|
@click.argument('service_name')
|
158
158
|
def create(**kwargs):
|
@@ -10,7 +10,7 @@ from stoobly_agent.app.models.types import RequestCreateParams, RequestDestroyPa
|
|
10
10
|
from stoobly_agent.app.proxy.mock.custom_not_found_response_builder import CustomNotFoundResponseBuilder
|
11
11
|
from stoobly_agent.app.proxy.mock.ignored_components_response_builder import IgnoreComponentsResponseBuilder
|
12
12
|
from stoobly_agent.app.proxy.record.joined_request import JoinedRequest
|
13
|
-
from stoobly_agent.config.constants import custom_headers
|
13
|
+
from stoobly_agent.config.constants import custom_headers, query_params as request_query_params
|
14
14
|
from stoobly_agent.lib.orm import ORM
|
15
15
|
from stoobly_agent.lib.orm.request import Request
|
16
16
|
from stoobly_agent.lib.orm.response import Response
|
@@ -73,7 +73,6 @@ class LocalDBRequestAdapter(LocalDBAdapter):
|
|
73
73
|
def response(self, **query_params: RequestColumns) -> requests.Response:
|
74
74
|
self.__adapt_scenario_id(query_params)
|
75
75
|
|
76
|
-
endpoint_promise = query_params.get('endpoint_promise')
|
77
76
|
request = None
|
78
77
|
|
79
78
|
if not query_params.get('request_id'):
|
@@ -85,16 +84,19 @@ class LocalDBRequestAdapter(LocalDBAdapter):
|
|
85
84
|
requests = self.__request_orm.where_for(**request_columns).get()
|
86
85
|
|
87
86
|
if 'scenario_id' in query_params:
|
88
|
-
# TODO: Would need an additional ID to distinguish different scenario sessions
|
89
|
-
session_id = generate_session_id(request_columns)
|
90
|
-
|
91
87
|
if len(requests) > 1:
|
92
|
-
|
88
|
+
session_id = query_params.get(request_query_params.SESSION_ID)
|
89
|
+
request_session_id_components = { **request_columns }
|
90
|
+
|
91
|
+
if session_id:
|
92
|
+
request_session_id_components[request_query_params.SESSION_ID] = session_id
|
93
|
+
|
94
|
+
# When multiple requests are matched for a scenario, return them in sequence
|
95
|
+
request_session_id = generate_session_id(request_session_id_components)
|
96
|
+
request = tiebreak_scenario_request(request_session_id, requests)
|
97
|
+
access_request(request_session_id, request.id)
|
93
98
|
else:
|
94
99
|
request = requests.last()
|
95
|
-
|
96
|
-
if request:
|
97
|
-
access_request(session_id, request.id)
|
98
100
|
else:
|
99
101
|
request = requests.last()
|
100
102
|
else:
|
@@ -104,6 +106,7 @@ class LocalDBRequestAdapter(LocalDBAdapter):
|
|
104
106
|
request = None
|
105
107
|
|
106
108
|
if not request:
|
109
|
+
endpoint_promise = query_params.get(request_query_params.ENDPOINT_PROMISE)
|
107
110
|
return self.__handle_request_not_found(endpoint_promise)
|
108
111
|
|
109
112
|
response_record = request.response
|
@@ -315,8 +318,8 @@ class LocalDBRequestAdapter(LocalDBAdapter):
|
|
315
318
|
return candidates.get()
|
316
319
|
|
317
320
|
def __filter_request_response_columns(self, request_columns: RequestCreateParams):
|
318
|
-
if request_columns.get(
|
319
|
-
del request_columns[
|
321
|
+
if request_columns.get(request_query_params.ENDPOINT_PROMISE):
|
322
|
+
del request_columns[request_query_params.ENDPOINT_PROMISE]
|
320
323
|
|
321
324
|
if request_columns.get('infer'):
|
322
325
|
del request_columns['infer']
|
@@ -327,6 +330,9 @@ class LocalDBRequestAdapter(LocalDBAdapter):
|
|
327
330
|
if request_columns.get('retry'):
|
328
331
|
del request_columns['retry']
|
329
332
|
|
333
|
+
if request_columns.get(request_query_params.SESSION_ID):
|
334
|
+
del request_columns[request_query_params.SESSION_ID]
|
335
|
+
|
330
336
|
def __request(self, request_id: str):
|
331
337
|
if self.validate_uuid(request_id):
|
332
338
|
return self.__request_orm.find_by(uuid=request_id)
|
@@ -3,7 +3,7 @@ import pdb
|
|
3
3
|
import requests
|
4
4
|
import time
|
5
5
|
|
6
|
-
from mitmproxy.http import Request as MitmproxyRequest
|
6
|
+
from mitmproxy.http import HTTPFlow as MitmproxyHTTPFlow, Request as MitmproxyRequest
|
7
7
|
from typing import Callable, TypedDict
|
8
8
|
|
9
9
|
from stoobly_agent.app.models.request_model import RequestModel
|
@@ -18,7 +18,8 @@ from .mock.eval_fixtures_service import eval_fixtures
|
|
18
18
|
from .mock.eval_request_service import inject_eval_request
|
19
19
|
from .utils.allowed_request_service import get_active_mode_policy
|
20
20
|
from .utils.request_handler import reverse_proxy
|
21
|
-
from .utils.response_handler import bad_request, pass_on
|
21
|
+
from .utils.response_handler import bad_request, enable_cors, pass_on
|
22
|
+
from .utils.rewrite import rewrite_request, rewrite_response
|
22
23
|
|
23
24
|
LOG_ID = 'Mock'
|
24
25
|
|
@@ -26,28 +27,51 @@ class MockOptions(TypedDict):
|
|
26
27
|
failure: Callable
|
27
28
|
ignored_components: list
|
28
29
|
infer: bool
|
30
|
+
no_rewrite: bool
|
29
31
|
success: Callable
|
30
32
|
|
33
|
+
def handle_request_mock_generic_without_rewrite(context: MockContext, **options: MockOptions):
|
34
|
+
options['no_rewrite'] = True
|
35
|
+
handle_request_mock_generic(context, **options)
|
36
|
+
|
31
37
|
###
|
32
38
|
#
|
39
|
+
# 1. Rewrites mock request by default
|
40
|
+
# 2. BEFORE_MOCK gets triggered
|
41
|
+
# 3. AFTER_MOCK gets triggered
|
42
|
+
#
|
33
43
|
# @param request [mitmproxy.http.Request]
|
34
44
|
# @param settings [Dict]
|
35
45
|
#
|
36
46
|
def handle_request_mock_generic(context: MockContext, **options: MockOptions):
|
37
|
-
|
38
|
-
|
47
|
+
handle_error = options['error'] if 'error' in options and callable(options['error']) else None
|
48
|
+
handle_failure = options['failure'] if 'failure' in options and callable(options['failure']) else None
|
49
|
+
handle_success = options['success'] if 'success' in options and callable(options['success']) else None
|
39
50
|
intercept_settings = context.intercept_settings
|
40
51
|
request: MitmproxyRequest = context.flow.request
|
41
|
-
|
52
|
+
res = None
|
42
53
|
|
43
54
|
policy = get_active_mode_policy(request, intercept_settings)
|
55
|
+
if policy == mock_policy.NONE:
|
56
|
+
if handle_error:
|
57
|
+
res = handle_error(context)
|
44
58
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
59
|
+
return pass_on(context.flow, res)
|
60
|
+
|
61
|
+
if policy not in [mock_policy.ALL, mock_policy.FOUND]:
|
62
|
+
if handle_error:
|
63
|
+
res = handle_error(context)
|
64
|
+
|
65
|
+
return bad_request(
|
66
|
+
context.flow,
|
67
|
+
"Valid env MOCK_POLICY: %s, Got: %s" %
|
68
|
+
([mock_policy.ALL, mock_policy.FOUND, mock_policy.NONE], policy)
|
69
|
+
)
|
70
|
+
|
71
|
+
if not options.get('no_rewrite'):
|
72
|
+
__rewrite_request(context)
|
73
|
+
|
74
|
+
__mock_hook(lifecycle_hooks.BEFORE_MOCK, context)
|
51
75
|
|
52
76
|
# If ignore rules are set, then ignore specified request parameters
|
53
77
|
ignore_rules = intercept_settings.ignore_rules
|
@@ -55,48 +79,33 @@ def handle_request_mock_generic(context: MockContext, **options: MockOptions):
|
|
55
79
|
request_facade = MitmproxyRequestFacade(request)
|
56
80
|
_ignore_rules = request_facade.select_parameter_rules(ignore_rules)
|
57
81
|
ignored_components = rewrite_rules_to_ignored_components(_ignore_rules)
|
58
|
-
options['ignored_components'] += ignored_components
|
82
|
+
options['ignored_components'] += ignored_components if 'ignored_components' in options else ignored_components
|
59
83
|
|
60
|
-
|
61
|
-
handle_failure = options['failure'] if 'failure' in options and callable(options['failure']) else None
|
62
|
-
|
84
|
+
request_model = RequestModel(intercept_settings.settings)
|
63
85
|
eval_request = inject_eval_request(request_model, intercept_settings)
|
64
|
-
|
65
|
-
if policy == mock_policy.
|
66
|
-
if handle_failure:
|
67
|
-
res = handle_failure(context)
|
68
|
-
elif policy == mock_policy.ALL:
|
86
|
+
|
87
|
+
if policy == mock_policy.ALL:
|
69
88
|
res = eval_request_with_retry(context, eval_request, **options)
|
70
89
|
|
71
90
|
context.with_response(res)
|
72
|
-
|
73
|
-
if handle_success:
|
74
|
-
# TODO: rewrite response, see #332
|
75
|
-
res = handle_success(context) or res
|
76
91
|
elif policy == mock_policy.FOUND:
|
77
92
|
res = eval_request_with_retry(context, eval_request, **options)
|
78
93
|
|
79
94
|
context.with_response(res)
|
80
95
|
|
81
96
|
if res.status_code in [custom_response_codes.NOT_FOUND, custom_response_codes.IGNORE_COMPONENTS]:
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
res = handle_success(context) or res
|
97
|
+
try:
|
98
|
+
res = __handle_found_policy(context)
|
99
|
+
except RuntimeError:
|
100
|
+
# Do nothing, return custom error response
|
101
|
+
pass
|
102
|
+
|
103
|
+
if res.status_code == custom_response_codes.NOT_FOUND:
|
104
|
+
if handle_failure:
|
105
|
+
res = handle_failure(context) or res
|
92
106
|
else:
|
93
|
-
|
94
|
-
context
|
95
|
-
"Valid env MOCK_POLICY: %s, Got: %s" %
|
96
|
-
([mock_policy.ALL, mock_policy.FOUND, mock_policy.NONE], policy)
|
97
|
-
)
|
98
|
-
|
99
|
-
__mock_hook(lifecycle_hooks.AFTER_MOCK, context)
|
107
|
+
if handle_success:
|
108
|
+
res = handle_success(context) or res
|
100
109
|
|
101
110
|
return pass_on(context.flow, res)
|
102
111
|
|
@@ -130,6 +139,11 @@ def handle_request_mock(context: MockContext):
|
|
130
139
|
success=lambda context: __handle_mock_success(context)
|
131
140
|
)
|
132
141
|
|
142
|
+
###
|
143
|
+
#
|
144
|
+
# 1. Rewrites mock response (if mock found)
|
145
|
+
# 2. AFTER_MOCK gets triggered (if mock found)
|
146
|
+
#
|
133
147
|
def handle_response_mock(context: MockContext):
|
134
148
|
response = context.flow.response
|
135
149
|
request_key = response.headers.get(custom_headers.MOCK_REQUEST_KEY)
|
@@ -138,13 +152,22 @@ def handle_response_mock(context: MockContext):
|
|
138
152
|
request = context.flow.request
|
139
153
|
Logger.instance(LOG_ID).info(f"{bcolors.OKCYAN}Mocked{bcolors.ENDC} {request.url} -> {request_key}")
|
140
154
|
|
141
|
-
|
142
|
-
|
143
|
-
response = context.response
|
144
|
-
start_time = context.start_time
|
145
|
-
__simulate_latency(response.headers.get(custom_headers.RESPONSE_LATENCY), start_time)
|
155
|
+
__rewrite_response(context)
|
156
|
+
__mock_hook(lifecycle_hooks.AFTER_MOCK, context)
|
146
157
|
|
147
158
|
def __handle_mock_failure(context: MockContext) -> None:
|
159
|
+
flow = context.flow
|
160
|
+
request = flow.request
|
161
|
+
|
162
|
+
if request.method.upper() != 'OPTIONS':
|
163
|
+
return False
|
164
|
+
|
165
|
+
# Default OPTIONS request to allow CORS
|
166
|
+
enable_cors(flow)
|
167
|
+
|
168
|
+
return True
|
169
|
+
|
170
|
+
def __handle_found_policy(context: MockContext) -> None:
|
148
171
|
req = context.flow.request
|
149
172
|
intercept_settings = context.intercept_settings
|
150
173
|
upstream_url = intercept_settings.upstream_url
|
@@ -160,6 +183,30 @@ def __handle_mock_failure(context: MockContext) -> None:
|
|
160
183
|
|
161
184
|
reverse_proxy(req, upstream_url, {})
|
162
185
|
|
186
|
+
def __handle_mock_success(context: MockContext) -> None:
|
187
|
+
if os.environ.get(env_vars.AGENT_SIMULATE_LATENCY):
|
188
|
+
response = context.response
|
189
|
+
start_time = context.start_time
|
190
|
+
__simulate_latency(response.headers.get(custom_headers.RESPONSE_LATENCY), start_time)
|
191
|
+
|
192
|
+
def __rewrite_request(context: MockContext):
|
193
|
+
# Rewrite request with paramter rules for mock
|
194
|
+
|
195
|
+
intercept_settings = context.intercept_settings
|
196
|
+
rewrite_rules = intercept_settings.mock_rewrite_rules
|
197
|
+
|
198
|
+
if len(rewrite_rules) > 0:
|
199
|
+
rewrite_request(context.flow, rewrite_rules)
|
200
|
+
|
201
|
+
def __rewrite_response(context: MockContext):
|
202
|
+
# Rewrite request with paramter rules for mock
|
203
|
+
|
204
|
+
intercept_settings = context.intercept_settings
|
205
|
+
rewrite_rules = intercept_settings.mock_rewrite_rules
|
206
|
+
|
207
|
+
if len(rewrite_rules) > 0:
|
208
|
+
rewrite_response(context.flow, rewrite_rules)
|
209
|
+
|
163
210
|
###
|
164
211
|
#
|
165
212
|
# Try to simulate expected response latency
|