stoobly-agent 1.10.1__py3-none-any.whl → 1.10.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. stoobly_agent/__init__.py +1 -1
  2. stoobly_agent/__main__.py +10 -0
  3. stoobly_agent/app/cli/ca_cert_cli.py +9 -5
  4. stoobly_agent/app/cli/helpers/replay_facade.py +2 -2
  5. stoobly_agent/app/cli/intercept_cli.py +5 -5
  6. stoobly_agent/app/cli/request_cli.py +2 -2
  7. stoobly_agent/app/cli/scaffold/app.py +14 -5
  8. stoobly_agent/app/cli/scaffold/app_command.py +0 -4
  9. stoobly_agent/app/cli/scaffold/app_config.py +49 -2
  10. stoobly_agent/app/cli/scaffold/app_create_command.py +145 -76
  11. stoobly_agent/app/cli/scaffold/constants.py +8 -1
  12. stoobly_agent/app/cli/scaffold/docker/constants.py +3 -1
  13. stoobly_agent/app/cli/scaffold/docker/service/build_decorator.py +2 -2
  14. stoobly_agent/app/cli/scaffold/docker/service/builder.py +15 -49
  15. stoobly_agent/app/cli/scaffold/docker/service/configure_gateway.py +3 -0
  16. stoobly_agent/app/cli/scaffold/docker/template_files.py +112 -0
  17. stoobly_agent/app/cli/scaffold/docker/workflow/build_decorator.py +1 -1
  18. stoobly_agent/app/cli/scaffold/docker/workflow/builder.py +31 -39
  19. stoobly_agent/app/cli/scaffold/docker/workflow/command_decorator.py +1 -1
  20. stoobly_agent/app/cli/scaffold/docker/workflow/detached_decorator.py +1 -1
  21. stoobly_agent/app/cli/scaffold/docker/workflow/dns_decorator.py +2 -3
  22. stoobly_agent/app/cli/scaffold/docker/workflow/local_decorator.py +1 -1
  23. stoobly_agent/app/cli/scaffold/docker/workflow/mock_decorator.py +1 -1
  24. stoobly_agent/app/cli/scaffold/docker/workflow/reverse_proxy_decorator.py +1 -1
  25. stoobly_agent/app/cli/scaffold/docker/workflow/run_command.py +423 -0
  26. stoobly_agent/app/cli/scaffold/local/__init__.py +0 -0
  27. stoobly_agent/app/cli/scaffold/local/service/__init__.py +0 -0
  28. stoobly_agent/app/cli/scaffold/local/service/builder.py +72 -0
  29. stoobly_agent/app/cli/scaffold/local/workflow/__init__.py +0 -0
  30. stoobly_agent/app/cli/scaffold/local/workflow/builder.py +35 -0
  31. stoobly_agent/app/cli/scaffold/local/workflow/run_command.py +339 -0
  32. stoobly_agent/app/cli/scaffold/service_command.py +9 -1
  33. stoobly_agent/app/cli/scaffold/service_config.py +8 -0
  34. stoobly_agent/app/cli/scaffold/service_create_command.py +18 -6
  35. stoobly_agent/app/cli/scaffold/service_docker_compose.py +1 -1
  36. stoobly_agent/app/cli/scaffold/templates/app/.Makefile +2 -2
  37. stoobly_agent/app/cli/scaffold/templates/app/build/.docker-compose.base.yml +2 -2
  38. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/.docker-compose.base.yml +2 -2
  39. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/run +3 -0
  40. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/run +3 -0
  41. stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/run +3 -0
  42. stoobly_agent/app/cli/scaffold/templates/build/services/build/mock/.configure +5 -1
  43. stoobly_agent/app/cli/scaffold/templates/build/services/build/mock/.init +5 -1
  44. stoobly_agent/app/cli/scaffold/templates/build/services/build/mock/.run +14 -0
  45. stoobly_agent/app/cli/scaffold/templates/build/services/build/record/.configure +5 -1
  46. stoobly_agent/app/cli/scaffold/templates/build/services/build/record/.init +5 -1
  47. stoobly_agent/app/cli/scaffold/templates/build/services/build/record/.run +14 -0
  48. stoobly_agent/app/cli/scaffold/templates/build/services/build/test/.configure +5 -1
  49. stoobly_agent/app/cli/scaffold/templates/build/services/build/test/.init +5 -1
  50. stoobly_agent/app/cli/scaffold/templates/build/services/build/test/.run +14 -0
  51. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/mock/.configure +5 -1
  52. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/mock/.init +5 -1
  53. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/mock/.run +19 -0
  54. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/record/.configure +5 -1
  55. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/record/.init +5 -1
  56. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/record/.run +19 -0
  57. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/test/.configure +5 -1
  58. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/test/.init +5 -1
  59. stoobly_agent/app/cli/scaffold/templates/build/services/entrypoint/test/.run +19 -0
  60. stoobly_agent/app/cli/scaffold/templates/build/workflows/exec/scaffold/.up +0 -1
  61. stoobly_agent/app/cli/scaffold/templates/build/workflows/mock/.configure +5 -1
  62. stoobly_agent/app/cli/scaffold/templates/build/workflows/mock/.init +5 -1
  63. stoobly_agent/app/cli/scaffold/templates/build/workflows/mock/.run +14 -0
  64. stoobly_agent/app/cli/scaffold/templates/build/workflows/record/.configure +5 -1
  65. stoobly_agent/app/cli/scaffold/templates/build/workflows/record/.init +5 -1
  66. stoobly_agent/app/cli/scaffold/templates/build/workflows/record/.run +14 -0
  67. stoobly_agent/app/cli/scaffold/templates/build/workflows/test/.configure +5 -1
  68. stoobly_agent/app/cli/scaffold/templates/build/workflows/test/.init +5 -1
  69. stoobly_agent/app/cli/scaffold/templates/build/workflows/test/.run +14 -0
  70. stoobly_agent/app/cli/scaffold/templates/constants.py +35 -19
  71. stoobly_agent/app/cli/scaffold/templates/factory.py +34 -18
  72. stoobly_agent/app/cli/scaffold/templates/plugins/cypress/test/.run +21 -0
  73. stoobly_agent/app/cli/scaffold/templates/plugins/playwright/test/.run +21 -0
  74. stoobly_agent/app/cli/scaffold/templates/workflow/mock/run +3 -0
  75. stoobly_agent/app/cli/scaffold/templates/workflow/record/run +3 -0
  76. stoobly_agent/app/cli/scaffold/templates/workflow/test/run +3 -0
  77. stoobly_agent/app/cli/scaffold/workflow_command.py +18 -4
  78. stoobly_agent/app/cli/scaffold/workflow_copy_command.py +5 -4
  79. stoobly_agent/app/cli/scaffold/workflow_create_command.py +31 -29
  80. stoobly_agent/app/cli/scaffold/workflow_run_command.py +18 -151
  81. stoobly_agent/app/cli/scaffold_cli.py +115 -161
  82. stoobly_agent/app/cli/scenario_cli.py +2 -2
  83. stoobly_agent/app/cli/types/test.py +2 -2
  84. stoobly_agent/app/cli/types/workflow_run_command.py +52 -3
  85. stoobly_agent/app/proxy/handle_mock_service.py +1 -1
  86. stoobly_agent/app/proxy/intercept_settings.py +5 -25
  87. stoobly_agent/app/proxy/mock/eval_fixtures_service.py +177 -27
  88. stoobly_agent/app/proxy/mock/types/__init__.py +22 -1
  89. stoobly_agent/app/proxy/replay/body_parser_service.py +8 -5
  90. stoobly_agent/app/proxy/replay/multipart.py +15 -13
  91. stoobly_agent/app/proxy/replay/replay_request_service.py +2 -2
  92. stoobly_agent/app/proxy/run.py +3 -0
  93. stoobly_agent/app/proxy/test/context.py +0 -4
  94. stoobly_agent/app/proxy/test/context_abc.py +0 -5
  95. stoobly_agent/cli.py +61 -16
  96. stoobly_agent/config/data_dir.py +0 -8
  97. stoobly_agent/public/12-es2015.618ecfd5f735b801b50f.js +1 -0
  98. stoobly_agent/public/12-es5.618ecfd5f735b801b50f.js +1 -0
  99. stoobly_agent/public/index.html +1 -1
  100. stoobly_agent/public/runtime-es2015.77bcd31efed9e5d5d431.js +1 -0
  101. stoobly_agent/public/runtime-es5.77bcd31efed9e5d5d431.js +1 -0
  102. stoobly_agent/test/app/cli/intercept/intercept_configure_test.py +17 -6
  103. stoobly_agent/test/app/cli/scaffold/docker/cli_invoker.py +177 -0
  104. stoobly_agent/test/app/cli/scaffold/{cli_test.py → docker/cli_test.py} +1 -8
  105. stoobly_agent/test/app/cli/scaffold/{e2e_test.py → docker/e2e_test.py} +31 -16
  106. stoobly_agent/test/app/cli/scaffold/local/__init__.py +0 -0
  107. stoobly_agent/test/app/cli/scaffold/{cli_invoker.py → local/cli_invoker.py} +38 -32
  108. stoobly_agent/test/app/cli/scaffold/local/e2e_test.py +342 -0
  109. stoobly_agent/test/app/models/schemas/.stoobly/db/VERSION +1 -1
  110. stoobly_agent/test/app/proxy/mock/eval_fixtures_service_test.py +903 -2
  111. stoobly_agent/test/app/proxy/replay/body_parser_service_test.py +95 -3
  112. stoobly_agent/test/config/data_dir_test.py +2 -7
  113. stoobly_agent/test/test_helper.py +16 -5
  114. {stoobly_agent-1.10.1.dist-info → stoobly_agent-1.10.2.dist-info}/METADATA +4 -2
  115. {stoobly_agent-1.10.1.dist-info → stoobly_agent-1.10.2.dist-info}/RECORD +150 -122
  116. {stoobly_agent-1.10.1.dist-info → stoobly_agent-1.10.2.dist-info}/WHEEL +1 -1
  117. stoobly_agent/app/cli/helpers/shell.py +0 -26
  118. stoobly_agent/public/12-es2015.be58ed0ef449008b932e.js +0 -1
  119. stoobly_agent/public/12-es5.be58ed0ef449008b932e.js +0 -1
  120. stoobly_agent/public/runtime-es2015.f8c814b38b27708e91c1.js +0 -1
  121. stoobly_agent/public/runtime-es5.f8c814b38b27708e91c1.js +0 -1
  122. /stoobly_agent/app/cli/scaffold/templates/app/build/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
  123. /stoobly_agent/app/cli/scaffold/templates/app/build/mock/{bin/configure → configure} +0 -0
  124. /stoobly_agent/app/cli/scaffold/templates/app/build/mock/{bin/init → init} +0 -0
  125. /stoobly_agent/app/cli/scaffold/templates/app/build/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
  126. /stoobly_agent/app/cli/scaffold/templates/app/build/record/{bin/configure → configure} +0 -0
  127. /stoobly_agent/app/cli/scaffold/templates/app/build/record/{bin/init → init} +0 -0
  128. /stoobly_agent/app/cli/scaffold/templates/app/build/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
  129. /stoobly_agent/app/cli/scaffold/templates/app/build/test/{bin/configure → configure} +0 -0
  130. /stoobly_agent/app/cli/scaffold/templates/app/build/test/{bin/init → init} +0 -0
  131. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
  132. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/{bin/configure → configure} +0 -0
  133. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/mock/{bin/init → init} +0 -0
  134. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
  135. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/{bin/configure → configure} +0 -0
  136. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/record/{bin/init → init} +0 -0
  137. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
  138. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/{bin/configure → configure} +0 -0
  139. /stoobly_agent/app/cli/scaffold/templates/app/entrypoint/test/{bin/init → init} +0 -0
  140. /stoobly_agent/app/cli/scaffold/templates/app/gateway/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
  141. /stoobly_agent/app/cli/scaffold/templates/app/gateway/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
  142. /stoobly_agent/app/cli/scaffold/templates/app/gateway/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
  143. /stoobly_agent/app/cli/scaffold/templates/app/stoobly-ui/exec/{.docker-compose.exec.yml → .docker-compose.yml} +0 -0
  144. /stoobly_agent/app/cli/scaffold/templates/app/stoobly-ui/mock/{.docker-compose.mock.yml → .docker-compose.yml} +0 -0
  145. /stoobly_agent/app/cli/scaffold/templates/app/stoobly-ui/record/{.docker-compose.record.yml → .docker-compose.yml} +0 -0
  146. /stoobly_agent/app/cli/scaffold/templates/plugins/cypress/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
  147. /stoobly_agent/app/cli/scaffold/templates/plugins/playwright/test/{.docker-compose.test.yml → .docker-compose.yml} +0 -0
  148. /stoobly_agent/app/cli/scaffold/templates/workflow/mock/{bin/configure → configure} +0 -0
  149. /stoobly_agent/app/cli/scaffold/templates/workflow/mock/{bin/init → init} +0 -0
  150. /stoobly_agent/app/cli/scaffold/templates/workflow/record/{bin/configure → configure} +0 -0
  151. /stoobly_agent/app/cli/scaffold/templates/workflow/record/{bin/init → init} +0 -0
  152. /stoobly_agent/app/cli/scaffold/templates/workflow/test/{bin/configure → configure} +0 -0
  153. /stoobly_agent/app/cli/scaffold/templates/workflow/test/{bin/init → init} +0 -0
  154. {stoobly_agent-1.10.1.dist-info → stoobly_agent-1.10.2.dist-info}/entry_points.txt +0 -0
  155. {stoobly_agent-1.10.1.dist-info → stoobly_agent-1.10.2.dist-info/licenses}/LICENSE +0 -0
@@ -2,25 +2,23 @@ import mimetypes
2
2
  import os
3
3
  import pdb
4
4
  import re
5
+ import yaml
5
6
 
6
7
  from io import BytesIO
7
8
  from mitmproxy.http import Request as MitmproxyRequest
8
9
  from requests import Response
9
10
  from requests.structures import CaseInsensitiveDict
10
- from typing import Optional, Union
11
+ from typing import List, Optional, Union
12
+ from urllib.parse import urlparse
11
13
 
12
14
  from stoobly_agent.lib.logger import bcolors, Logger
13
15
  from stoobly_agent.config.constants.custom_headers import MOCK_FIXTURE_PATH
14
16
 
15
- from .types import Fixtures
17
+ from .types import Fixtures, MockOptions, PublicDirectoryPath, ResponseFixturesPath
16
18
 
17
19
  LOG_ID = 'Fixture'
18
20
 
19
- 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
@@ -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):
stoobly_agent/cli.py CHANGED
@@ -9,8 +9,10 @@ from stoobly_agent import VERSION
9
9
  from stoobly_agent.app.cli.helpers.context import ReplayContext
10
10
  from stoobly_agent.app.cli.helpers.handle_mock_service import print_raw_response, RAW_FORMAT
11
11
  from stoobly_agent.app.cli.helpers.validations import validate_project_key, validate_scenario_key
12
+ from stoobly_agent.app.cli.intercept_cli import mode_options
12
13
  from stoobly_agent.app.proxy.constants import custom_response_codes
13
14
  from stoobly_agent.app.proxy.replay.replay_request_service import replay as replay_request
15
+ from stoobly_agent.app.settings.constants import intercept_mode
14
16
  from stoobly_agent.config.constants import env_vars, mode
15
17
  from stoobly_agent.config.data_dir import DataDir
16
18
  from stoobly_agent.lib.logger import Logger
@@ -80,7 +82,7 @@ def init(**kwargs):
80
82
  help="Run proxy and/or UI",
81
83
  )
82
84
  @ConditionalDecorator(lambda f: click.option('--api-url', help='API URL.')(f), is_remote)
83
- @click.option('--ca-certs-dir-path', default=DataDir.instance().ca_certs_dir_path, help='Path to ca certs directory used to sign SSL certs.')
85
+ @click.option('--ca-certs-dir-path', default=os.path.join(os.path.expanduser('~'), '.mitmproxy'), help='Path to ca certs directory used to sign SSL certs.')
84
86
  @click.option('--certs', help='''
85
87
  SSL certificates of the form "[domain=]path". The domain may include a wildcard, and is equal to "*" if not specified. The file at path is a certificate in PEM format. If a private key is included in the
86
88
  PEM, it is used, else the default key in the conf dir is used. The PEM file should contain the full certificate chain, with the leaf certificate as the first entry. May be passed multiple times.
@@ -90,6 +92,7 @@ def init(**kwargs):
90
92
  config.yaml to avoid this.
91
93
  ''')
92
94
  @click.option('--connection-strategy', help=', '.join(CONNECTION_STRATEGIES), type=click.Choice(CONNECTION_STRATEGIES))
95
+ @click.option('--detached', type=click.Path(), help='Run in detached mode and redirect output to the specified file path.')
93
96
  @click.option('--flow-detail', default='1', type=click.Choice(['0', '1', '2', '3', '4']), help='''
94
97
  The display detail level for flows in mitmdump: 0 (quiet) to 4 (very verbose).
95
98
  0: no output
@@ -100,7 +103,7 @@ def init(**kwargs):
100
103
  ''')
101
104
  @click.option('--headless', is_flag=True, default=False, help='Disable starting UI.')
102
105
  @click.option('--intercept', is_flag=True, default=False, help='Enable intercept on run.')
103
- @click.option('--intercept-mode', help='Set intercept mode.')
106
+ @click.option('--intercept-mode', type=click.Choice(mode_options), help='Set intercept mode.')
104
107
  @click.option('--log-level', default=logger.INFO, type=click.Choice([logger.DEBUG, logger.INFO, logger.WARNING, logger.ERROR]), help='''
105
108
  Log levels can be "debug", "info", "warning", or "error"
106
109
  ''')
@@ -118,8 +121,8 @@ def init(**kwargs):
118
121
  the form of "http[s]://host[:port]".
119
122
  ''')
120
123
  @click.option('--proxy-port', default=8080, type=click.IntRange(1, 65535), help='Proxy service port.')
121
- @click.option('--public-directory-path', help='Path to public files. Used for mocking requests.')
122
- @click.option('--response-fixtures-path', help='Path to response fixtures yaml. Used for mocking requests.')
124
+ @click.option('--public-directory-path', multiple=True, help='Path to public files. Used for mocking requests. Can take the form <FOLDER-PATH>[:<ORIGIN>].')
125
+ @click.option('--response-fixtures-path', multiple=True, help='Path to response fixtures yaml. Used for mocking requests. Can take the form <FILE-PATH>[:<ORIGIN>].')
123
126
  @click.option('--ssl-insecure', is_flag=True, default=False, help='Do not verify upstream server SSL/TLS certificates.')
124
127
  @click.option('--ui-host', default='0.0.0.0', help='Address to bind UI to.')
125
128
  @click.option('--ui-port', default=4200, type=click.IntRange(1, 65535), help='UI service port.')
@@ -130,6 +133,9 @@ def run(**kwargs):
130
133
  settings: Settings = Settings.instance()
131
134
  settings.watch()
132
135
 
136
+ if not os.path.exists(kwargs.get('ca_certs_dir_path')):
137
+ kwargs['ca_certs_dir_path'] = DataDir.instance().ca_certs_dir_path
138
+
133
139
  if kwargs.get('headless'):
134
140
  os.environ[env_vars.AGENT_HEADLESS] = '1'
135
141
 
@@ -144,10 +150,18 @@ def run(**kwargs):
144
150
  os.environ[env_vars.AGENT_LIFECYCLE_HOOKS_PATH] = kwargs['lifecycle_hooks_path']
145
151
 
146
152
  if kwargs.get('public_directory_path'):
147
- os.environ[env_vars.AGENT_PUBLIC_DIRECTORY_PATH] = kwargs['public_directory_path']
153
+ # Join multiple paths with commas
154
+ public_dirs = kwargs['public_directory_path']
155
+ if isinstance(public_dirs, (list, tuple)):
156
+ os.environ[env_vars.AGENT_PUBLIC_DIRECTORY_PATH] = ','.join(public_dirs)
157
+ else:
158
+ os.environ[env_vars.AGENT_PUBLIC_DIRECTORY_PATH] = public_dirs
148
159
 
149
160
  if kwargs.get('response_fixtures_path'):
150
- os.environ[env_vars.AGENT_RESPONSE_FIXTURES_PATH] = kwargs['response_fixtures_path']
161
+ response_fixtures_paths = kwargs.get('response_fixtures_path', ())
162
+ if response_fixtures_paths:
163
+ response_fixtures = ','.join(response_fixtures_paths)
164
+ os.environ[env_vars.AGENT_RESPONSE_FIXTURES_PATH] = response_fixtures
151
165
 
152
166
  if not os.getenv(env_vars.LOG_LEVEL):
153
167
  os.environ[env_vars.LOG_LEVEL] = kwargs['log_level']
@@ -166,15 +180,46 @@ def run(**kwargs):
166
180
  os.environ[env_vars.AGENT_PROXY_URL] = proxy_url
167
181
  settings.proxy.url = proxy_url
168
182
 
169
- if not kwargs.get('headless'):
170
- settings.commit()
171
- run_api(**kwargs)
183
+ if kwargs.get('detached'):
184
+ # Run in detached mode with output redirection
185
+ import subprocess
186
+ import sys
187
+
188
+ # Build the command to run in background
189
+ cmd = [sys.executable, '-m', 'stoobly_agent'] + sys.argv[1:]
190
+ # Remove the --detached flag and its value from the command
191
+ detached_index = None
192
+ for i, arg in enumerate(cmd):
193
+ if arg == '--detached':
194
+ detached_index = i
195
+ break
196
+ if detached_index is not None:
197
+ cmd.pop(detached_index) # Remove --detached
198
+ if detached_index < len(cmd) and not cmd[detached_index].startswith('--'):
199
+ cmd.pop(detached_index) # Remove the file path
200
+
201
+ # Start the process in background with output redirection
202
+ with open(kwargs['detached'], 'w') as output_file:
203
+ process = subprocess.Popen(
204
+ cmd,
205
+ stdout=output_file,
206
+ stderr=subprocess.STDOUT, # Redirect stderr to stdout
207
+ preexec_fn=os.setsid # Create new process group
208
+ )
209
+
210
+ print(process.pid)
211
+ return
212
+ else:
213
+ # Run in foreground mode
214
+ if not kwargs.get('headless'):
215
+ settings.commit()
216
+ run_api(**kwargs)
172
217
 
173
- if not kwargs.get('proxyless'):
174
- log_id = 'Proxy'
175
- Logger.instance(log_id).info(f"starting with mode {kwargs['proxy_mode']} and listening at {kwargs['proxy_host']}:{kwargs['proxy_port']}")
176
- Logger.instance(log_id).info(f"{'' if settings.proxy.intercept.active else 'not yet '}configured to {settings.proxy.intercept.mode}")
177
- run_proxy(**kwargs)
218
+ if not kwargs.get('proxyless'):
219
+ log_id = 'Proxy'
220
+ Logger.instance(log_id).info(f"starting with mode {kwargs['proxy_mode']} and listening at {kwargs['proxy_host']}:{kwargs['proxy_port']}")
221
+ Logger.instance(log_id).info(f"{'' if settings.proxy.intercept.active else 'not yet '}configured to {settings.proxy.intercept.mode}")
222
+ run_proxy(**kwargs)
178
223
 
179
224
  @main.command(
180
225
  help="Mock request"
@@ -186,8 +231,8 @@ def run(**kwargs):
186
231
  @click.option('--lifecycle-hooks-path', help='Path to lifecycle hooks script.')
187
232
  @click.option('-o', '--output', help='Write to file instead of stdout')
188
233
  @ConditionalDecorator(lambda f: click.option('--project-key')(f), is_remote)
189
- @click.option('--public-directory-path', help='Path to public files. Used for mocking requests.')
190
- @click.option('--response-fixtures-path', help='Path to response fixtures yaml. Used for mocking requests.')
234
+ @click.option('--public-directory-path', multiple=True, help='Path to public files. Used for mocking requests. Can take the form <FOLDER-PATH>[:<ORIGIN>].')
235
+ @click.option('--response-fixtures-path', multiple=True, help='Path to response fixtures yaml. Used for mocking requests. Can take the form <FILE-PATH>[:<ORIGIN>].')
191
236
  @click.option('-X', '--request', default='GET', help='Specify request command to use')
192
237
  @click.option('--scenario-key')
193
238
  @click.argument('url')
@@ -56,14 +56,6 @@ class DataDir:
56
56
 
57
57
  @property
58
58
  def path(self):
59
- if not self.__path and os.environ.get(ENV) == 'test':
60
- test_path = os.path.join(self.__data_dir_path, TMP_DIR_NAME, DATA_DIR_NAME)
61
-
62
- if not os.path.exists(test_path):
63
- os.makedirs(test_path, exist_ok=True)
64
-
65
- return test_path
66
-
67
59
  return self.__data_dir_path
68
60
 
69
61
  @property