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.
Files changed (173) hide show
  1. stoobly_agent/__init__.py +1 -1
  2. stoobly_agent/__main__.py +10 -0
  3. stoobly_agent/app/api/application_http_request_handler.py +5 -2
  4. stoobly_agent/app/cli/ca_cert_cli.py +9 -5
  5. stoobly_agent/app/cli/helpers/replay_facade.py +2 -2
  6. stoobly_agent/app/cli/intercept_cli.py +5 -5
  7. stoobly_agent/app/cli/request_cli.py +2 -2
  8. stoobly_agent/app/cli/scaffold/app.py +14 -5
  9. stoobly_agent/app/cli/scaffold/app_command.py +0 -4
  10. stoobly_agent/app/cli/scaffold/app_config.py +49 -2
  11. stoobly_agent/app/cli/scaffold/app_create_command.py +145 -76
  12. stoobly_agent/app/cli/scaffold/constants.py +9 -4
  13. stoobly_agent/app/cli/scaffold/docker/constants.py +3 -1
  14. stoobly_agent/app/cli/scaffold/docker/service/build_decorator.py +4 -4
  15. stoobly_agent/app/cli/scaffold/docker/service/builder.py +31 -54
  16. stoobly_agent/app/cli/scaffold/docker/service/configure_gateway.py +3 -0
  17. stoobly_agent/app/cli/scaffold/docker/template_files.py +112 -0
  18. stoobly_agent/app/cli/scaffold/docker/workflow/build_decorator.py +1 -1
  19. stoobly_agent/app/cli/scaffold/docker/workflow/builder.py +30 -47
  20. stoobly_agent/app/cli/scaffold/docker/workflow/command_decorator.py +3 -2
  21. stoobly_agent/app/cli/scaffold/docker/workflow/detached_decorator.py +1 -1
  22. stoobly_agent/app/cli/scaffold/docker/workflow/dns_decorator.py +2 -3
  23. stoobly_agent/app/cli/scaffold/docker/workflow/local_decorator.py +1 -1
  24. stoobly_agent/app/cli/scaffold/docker/workflow/mock_decorator.py +1 -1
  25. stoobly_agent/app/cli/scaffold/docker/workflow/reverse_proxy_decorator.py +1 -1
  26. stoobly_agent/app/cli/scaffold/docker/workflow/run_command.py +423 -0
  27. stoobly_agent/app/cli/scaffold/local/__init__.py +0 -0
  28. stoobly_agent/app/cli/scaffold/local/service/__init__.py +0 -0
  29. stoobly_agent/app/cli/scaffold/local/service/builder.py +72 -0
  30. stoobly_agent/app/cli/scaffold/local/workflow/__init__.py +0 -0
  31. stoobly_agent/app/cli/scaffold/local/workflow/builder.py +35 -0
  32. stoobly_agent/app/cli/scaffold/local/workflow/run_command.py +339 -0
  33. stoobly_agent/app/cli/scaffold/service_command.py +9 -1
  34. stoobly_agent/app/cli/scaffold/service_config.py +9 -25
  35. stoobly_agent/app/cli/scaffold/service_create_command.py +18 -6
  36. stoobly_agent/app/cli/scaffold/service_docker_compose.py +3 -3
  37. stoobly_agent/app/cli/scaffold/service_workflow_validate_command.py +10 -7
  38. stoobly_agent/app/cli/scaffold/templates/app/.Makefile +2 -2
  39. stoobly_agent/app/cli/scaffold/templates/app/build/.docker-compose.base.yml +4 -4
  40. stoobly_agent/app/cli/scaffold/templates/app/build/mock/configure +3 -0
  41. stoobly_agent/app/cli/scaffold/templates/app/build/record/configure +28 -0
  42. stoobly_agent/app/cli/scaffold/templates/app/build/test/configure +3 -0
  43. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/.docker-compose.base.yml +4 -4
  44. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/configure +3 -0
  45. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/run +3 -0
  46. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/configure +3 -0
  47. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/run +3 -0
  48. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/configure +3 -0
  49. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/run +3 -0
  50. stoobly_agent/app/cli/scaffold/templates/build/services/build/mock/.configure +5 -1
  51. stoobly_agent/app/cli/scaffold/templates/build/services/build/mock/.init +5 -1
  52. stoobly_agent/app/cli/scaffold/templates/build/services/build/mock/.run +14 -0
  53. stoobly_agent/app/cli/scaffold/templates/build/services/build/record/.configure +5 -1
  54. stoobly_agent/app/cli/scaffold/templates/build/services/build/record/.init +5 -1
  55. stoobly_agent/app/cli/scaffold/templates/build/services/build/record/.run +14 -0
  56. stoobly_agent/app/cli/scaffold/templates/build/services/build/test/.configure +5 -1
  57. stoobly_agent/app/cli/scaffold/templates/build/services/build/test/.init +5 -1
  58. stoobly_agent/app/cli/scaffold/templates/build/services/build/test/.run +14 -0
  59. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/mock/.configure +5 -1
  60. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/mock/.init +5 -1
  61. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/mock/.run +19 -0
  62. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/record/.configure +5 -1
  63. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/record/.init +5 -1
  64. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/record/.run +19 -0
  65. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/test/.configure +5 -1
  66. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/test/.init +5 -1
  67. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/test/.run +19 -0
  68. stoobly_agent/app/cli/scaffold/templates/build/workflows/exec/scaffold/.up +0 -1
  69. stoobly_agent/app/cli/scaffold/templates/build/workflows/mock/.configure +5 -1
  70. stoobly_agent/app/cli/scaffold/templates/build/workflows/mock/.init +5 -1
  71. stoobly_agent/app/cli/scaffold/templates/build/workflows/mock/.run +14 -0
  72. stoobly_agent/app/cli/scaffold/templates/build/workflows/record/.configure +25 -1
  73. stoobly_agent/app/cli/scaffold/templates/build/workflows/record/.init +5 -1
  74. stoobly_agent/app/cli/scaffold/templates/build/workflows/record/.run +14 -0
  75. stoobly_agent/app/cli/scaffold/templates/build/workflows/test/.configure +5 -1
  76. stoobly_agent/app/cli/scaffold/templates/build/workflows/test/.init +5 -1
  77. stoobly_agent/app/cli/scaffold/templates/build/workflows/test/.run +14 -0
  78. stoobly_agent/app/cli/scaffold/templates/constants.py +35 -19
  79. stoobly_agent/app/cli/scaffold/templates/factory.py +34 -18
  80. stoobly_agent/app/cli/scaffold/templates/plugins/cypress/test/.run +21 -0
  81. stoobly_agent/app/cli/scaffold/templates/plugins/playwright/test/.run +21 -0
  82. stoobly_agent/app/cli/scaffold/templates/workflow/mock/configure +5 -0
  83. stoobly_agent/app/cli/scaffold/templates/workflow/mock/run +3 -0
  84. stoobly_agent/app/cli/scaffold/templates/workflow/record/configure +21 -0
  85. stoobly_agent/app/cli/scaffold/templates/workflow/record/run +3 -0
  86. stoobly_agent/app/cli/scaffold/templates/workflow/test/configure +5 -0
  87. stoobly_agent/app/cli/scaffold/templates/workflow/test/run +3 -0
  88. stoobly_agent/app/cli/scaffold/workflow_command.py +18 -4
  89. stoobly_agent/app/cli/scaffold/workflow_copy_command.py +5 -4
  90. stoobly_agent/app/cli/scaffold/workflow_create_command.py +31 -29
  91. stoobly_agent/app/cli/scaffold/workflow_run_command.py +18 -151
  92. stoobly_agent/app/cli/scaffold_cli.py +134 -182
  93. stoobly_agent/app/cli/scenario_cli.py +2 -2
  94. stoobly_agent/app/cli/types/test.py +2 -2
  95. stoobly_agent/app/cli/types/workflow_run_command.py +52 -3
  96. stoobly_agent/app/proxy/handle_mock_service.py +1 -1
  97. stoobly_agent/app/proxy/intercept_settings.py +6 -26
  98. stoobly_agent/app/proxy/mock/eval_fixtures_service.py +177 -27
  99. stoobly_agent/app/proxy/mock/types/__init__.py +22 -1
  100. stoobly_agent/app/proxy/record/upload_request_service.py +3 -6
  101. stoobly_agent/app/proxy/replay/body_parser_service.py +8 -5
  102. stoobly_agent/app/proxy/replay/multipart.py +15 -13
  103. stoobly_agent/app/proxy/replay/replay_request_service.py +2 -2
  104. stoobly_agent/app/proxy/run.py +3 -0
  105. stoobly_agent/app/proxy/test/context.py +0 -4
  106. stoobly_agent/app/proxy/test/context_abc.py +0 -5
  107. stoobly_agent/app/proxy/utils/publish_change_service.py +20 -23
  108. stoobly_agent/app/settings/__init__.py +10 -7
  109. stoobly_agent/cli.py +61 -16
  110. stoobly_agent/config/data_dir.py +1 -8
  111. stoobly_agent/public/12-es2015.618ecfd5f735b801b50f.js +1 -0
  112. stoobly_agent/public/12-es5.618ecfd5f735b801b50f.js +1 -0
  113. stoobly_agent/public/index.html +1 -1
  114. stoobly_agent/public/main-es2015.5a9aa16433404c3f423a.js +1 -0
  115. stoobly_agent/public/main-es5.5a9aa16433404c3f423a.js +1 -0
  116. stoobly_agent/public/runtime-es2015.77bcd31efed9e5d5d431.js +1 -0
  117. stoobly_agent/public/runtime-es5.77bcd31efed9e5d5d431.js +1 -0
  118. stoobly_agent/test/app/cli/intercept/intercept_configure_test.py +17 -6
  119. stoobly_agent/test/app/cli/scaffold/docker/cli_invoker.py +177 -0
  120. stoobly_agent/test/app/cli/scaffold/{cli_test.py → docker/cli_test.py} +4 -11
  121. stoobly_agent/test/app/cli/scaffold/{e2e_test.py → docker/e2e_test.py} +42 -27
  122. stoobly_agent/test/app/cli/scaffold/local/__init__.py +0 -0
  123. stoobly_agent/test/app/cli/scaffold/{cli_invoker.py → local/cli_invoker.py} +38 -32
  124. stoobly_agent/test/app/cli/scaffold/local/e2e_test.py +342 -0
  125. stoobly_agent/test/app/models/schemas/.stoobly/db/VERSION +1 -1
  126. stoobly_agent/test/app/proxy/mock/eval_fixtures_service_test.py +903 -2
  127. stoobly_agent/test/app/proxy/replay/body_parser_service_test.py +95 -3
  128. stoobly_agent/test/config/data_dir_test.py +2 -7
  129. stoobly_agent/test/test_helper.py +16 -5
  130. {stoobly_agent-1.10.0.dist-info → stoobly_agent-1.10.2.dist-info}/METADATA +4 -2
  131. {stoobly_agent-1.10.0.dist-info → stoobly_agent-1.10.2.dist-info}/RECORD +157 -129
  132. {stoobly_agent-1.10.0.dist-info → stoobly_agent-1.10.2.dist-info}/WHEEL +1 -1
  133. stoobly_agent/app/cli/helpers/shell.py +0 -26
  134. stoobly_agent/app/cli/scaffold/templates/app/build/mock/bin/configure +0 -3
  135. stoobly_agent/app/cli/scaffold/templates/app/build/record/bin/configure +0 -3
  136. stoobly_agent/app/cli/scaffold/templates/app/build/test/bin/configure +0 -3
  137. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/bin/configure +0 -3
  138. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/bin/configure +0 -3
  139. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/bin/configure +0 -3
  140. stoobly_agent/app/cli/scaffold/templates/workflow/mock/bin/configure +0 -13
  141. stoobly_agent/app/cli/scaffold/templates/workflow/record/bin/configure +0 -47
  142. stoobly_agent/app/cli/scaffold/templates/workflow/test/bin/configure +0 -13
  143. stoobly_agent/public/12-es2015.be58ed0ef449008b932e.js +0 -1
  144. stoobly_agent/public/12-es5.be58ed0ef449008b932e.js +0 -1
  145. stoobly_agent/public/main-es2015.089b46f303768fbe864f.js +0 -1
  146. stoobly_agent/public/main-es5.089b46f303768fbe864f.js +0 -1
  147. stoobly_agent/public/runtime-es2015.f8c814b38b27708e91c1.js +0 -1
  148. stoobly_agent/public/runtime-es5.f8c814b38b27708e91c1.js +0 -1
  149. /stoobly_agent/app/cli/scaffold/templates/app/build/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
  150. /stoobly_agent/app/cli/scaffold/templates/app/build/mock/{bin/init → init} +0 -0
  151. /stoobly_agent/app/cli/scaffold/templates/app/build/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
  152. /stoobly_agent/app/cli/scaffold/templates/app/build/record/{bin/init → init} +0 -0
  153. /stoobly_agent/app/cli/scaffold/templates/app/build/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
  154. /stoobly_agent/app/cli/scaffold/templates/app/build/test/{bin/init → init} +0 -0
  155. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
  156. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/{bin/init → init} +0 -0
  157. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
  158. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/{bin/init → init} +0 -0
  159. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
  160. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/{bin/init → init} +0 -0
  161. /stoobly_agent/app/cli/scaffold/templates/app/gateway/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
  162. /stoobly_agent/app/cli/scaffold/templates/app/gateway/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
  163. /stoobly_agent/app/cli/scaffold/templates/app/gateway/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
  164. /stoobly_agent/app/cli/scaffold/templates/app/stoobly-ui/exec/{.docker-compose.exec.yml → .docker-compose.yml} +0 -0
  165. /stoobly_agent/app/cli/scaffold/templates/app/stoobly-ui/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
  166. /stoobly_agent/app/cli/scaffold/templates/app/stoobly-ui/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
  167. /stoobly_agent/app/cli/scaffold/templates/plugins/cypress/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
  168. /stoobly_agent/app/cli/scaffold/templates/plugins/playwright/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
  169. /stoobly_agent/app/cli/scaffold/templates/workflow/mock/{bin/init → init} +0 -0
  170. /stoobly_agent/app/cli/scaffold/templates/workflow/record/{bin/init → init} +0 -0
  171. /stoobly_agent/app/cli/scaffold/templates/workflow/test/{bin/init → init} +0 -0
  172. {stoobly_agent-1.10.0.dist-info → stoobly_agent-1.10.2.dist-info}/entry_points.txt +0 -0
  173. {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
- if os.environ.get(env_vars.AGENT_PUBLIC_DIRECTORY_PATH):
117
- return os.environ[env_vars.AGENT_PUBLIC_DIRECTORY_PATH]
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.WEB
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
- class Options():
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
- fixture: dict = __eval_response_fixtures(request, response_fixtures)
31
+
32
+ # Try response fixtures in order of preference
33
+ fixture = None
34
34
 
35
- if not fixture:
36
- public_directory_path = options.get('public_directory_path')
35
+ if response_fixtures:
36
+ fixture = __find_fixture_for_request(request, response_fixtures, request.method)
37
37
 
38
- if not public_directory_path:
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
- _fixture_path = os.path.join(public_directory_path, request_path.lstrip('/'))
43
- if request.headers.get('accept'):
44
- fixture_path = __guess_file_path(_fixture_path, request.headers['accept'])
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 __eval_response_fixtures(request: MitmproxyRequest, response_fixtures: Fixtures):
111
- if not isinstance(response_fixtures, dict):
112
- return
113
-
114
- method = request.method
115
- routes = response_fixtures.get(method)
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, request.path):
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
- # If request_origin is WEB, then we are in proxy
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, sync=True):
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'], sync=sync)
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
- headers = {'content-type': content_type}
73
- decoded_multipart = multipart_decode(headers, content)
72
+ try:
73
+ headers = {'content-type': content_type}
74
+ decoded_multipart = multipart_decode(headers, content)
74
75
 
75
- if not decoded_multipart:
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
- parser = MultipartParser(io.BytesIO(content), boundary=boundary)
71
- for part in parser.parts():
72
- if part.content_type.lower() == 'application/octet-stream':
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
- # Free up resources after use
78
- part.close()
79
- return r
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
@@ -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
- class Options(TypedDict):
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 ui is not active, return
24
- if not settings.ui.active:
25
- return False
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
- def publish_requests_modified(value, **options: Options):
38
- return publish_change(REQUESTS_MODIFIED, value, **options)
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
- api.update_status(status, value)
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, statuses
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 = ['settings.yml']
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
- path = self.__settings_file_path
184
- lock = FileLock(path + ".lock") # lock file alongside the target
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(path, 'w') as fp:
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 publish_change
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
- publish_change(statuses.SETTINGS_MODIFIED, self.__settings, sync=True)
227
+ if self.__ui_settings.active:
228
+ publish_settings_modified(self.__settings)
226
229
 
227
230
  self.__load_lock = False