stoobly-agent 1.4.1__py3-none-any.whl → 1.5.0__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 (65) hide show
  1. stoobly_agent/__init__.py +1 -1
  2. stoobly_agent/app/cli/helpers/handle_mock_service.py +6 -2
  3. stoobly_agent/app/cli/helpers/request_facade.py +5 -1
  4. stoobly_agent/app/cli/scaffold/constants.py +1 -1
  5. stoobly_agent/app/cli/scaffold/docker/workflow/mock_decorator.py +3 -2
  6. stoobly_agent/app/cli/scaffold/docker/workflow/reverse_proxy_decorator.py +2 -2
  7. stoobly_agent/app/cli/scaffold/service_config.py +16 -2
  8. stoobly_agent/app/cli/scaffold/service_workflow_validate_command.py +19 -19
  9. stoobly_agent/app/cli/scaffold/templates/app/.Dockerfile.context +1 -1
  10. stoobly_agent/app/cli/scaffold/templates/app/.Makefile +12 -5
  11. stoobly_agent/app/cli/scaffold/templates/constants.py +3 -3
  12. stoobly_agent/app/cli/scaffold/templates/factory.py +5 -5
  13. stoobly_agent/app/cli/scaffold/templates/workflow/mock/bin/configure +1 -8
  14. stoobly_agent/app/cli/scaffold/templates/workflow/mock/fixtures.yml +1 -1
  15. stoobly_agent/app/cli/scaffold/templates/workflow/test/bin/configure +1 -8
  16. stoobly_agent/app/cli/scaffold/templates/workflow/test/fixtures.yml +1 -1
  17. stoobly_agent/app/cli/scaffold/workflow_command.py +3 -3
  18. stoobly_agent/app/cli/scaffold/workflow_create_command.py +2 -2
  19. stoobly_agent/app/cli/scaffold_cli.py +65 -50
  20. stoobly_agent/app/proxy/context.py +4 -0
  21. stoobly_agent/app/proxy/handle_mock_service.py +81 -54
  22. stoobly_agent/app/proxy/handle_record_service.py +15 -3
  23. stoobly_agent/app/proxy/handle_replay_service.py +44 -18
  24. stoobly_agent/app/proxy/handle_test_service.py +75 -16
  25. stoobly_agent/app/proxy/intercept_handler.py +11 -16
  26. stoobly_agent/app/proxy/intercept_settings.py +17 -4
  27. stoobly_agent/app/proxy/mitmproxy/request_facade.py +5 -2
  28. stoobly_agent/app/proxy/mitmproxy/response_facade.py +5 -4
  29. stoobly_agent/app/proxy/mock/eval_fixtures_service.py +78 -14
  30. stoobly_agent/app/proxy/mock/eval_request_service.py +2 -2
  31. stoobly_agent/app/proxy/record/join_request_service.py +7 -8
  32. stoobly_agent/app/proxy/record/upload_request_service.py +2 -2
  33. stoobly_agent/app/proxy/replay/replay_request_service.py +4 -4
  34. stoobly_agent/app/proxy/test/helpers/upload_test_service.py +2 -2
  35. stoobly_agent/app/proxy/utils/allowed_request_service.py +3 -3
  36. stoobly_agent/app/proxy/utils/response_handler.py +0 -2
  37. stoobly_agent/app/proxy/utils/rewrite.py +72 -0
  38. stoobly_agent/app/settings/constants/request_component.py +4 -1
  39. stoobly_agent/cli.py +35 -28
  40. stoobly_agent/config/constants/intercept_policy.py +2 -0
  41. stoobly_agent/config/constants/mock_policy.py +4 -2
  42. stoobly_agent/config/constants/record_policy.py +4 -2
  43. stoobly_agent/config/constants/replay_policy.py +4 -2
  44. stoobly_agent/public/{18-es2015.583f191cc7ad512ee262.js → 18-es2015.503207073756a9c8211a.js} +1 -1
  45. stoobly_agent/public/{18-es5.583f191cc7ad512ee262.js → 18-es5.503207073756a9c8211a.js} +1 -1
  46. stoobly_agent/public/index.html +1 -1
  47. stoobly_agent/public/{main-es2015.2cc16523aa3fcaba51e5.js → main-es2015.d682619f3d6d53d64c6a.js} +1 -1
  48. stoobly_agent/public/{main-es5.2cc16523aa3fcaba51e5.js → main-es5.d682619f3d6d53d64c6a.js} +1 -1
  49. stoobly_agent/public/{runtime-es2015.b914470164e4d6e75d96.js → runtime-es2015.8c1efed946fc02c923fc.js} +1 -1
  50. stoobly_agent/public/{runtime-es5.b914470164e4d6e75d96.js → runtime-es5.8c1efed946fc02c923fc.js} +1 -1
  51. stoobly_agent/test/app/cli/helpers/openapi_endpoint_adapter_test.py +2 -1
  52. stoobly_agent/test/app/cli/scaffold/e2e_test.py +2 -2
  53. stoobly_agent/test/app/models/schemas/.stoobly/db/VERSION +1 -1
  54. stoobly_agent/test/app/proxy/mock/eval_fixtures_service_test.py +140 -71
  55. stoobly_agent/test/cli/lifecycle_hooks_test.py +66 -0
  56. stoobly_agent/test/cli/mock_test.py +53 -29
  57. stoobly_agent/test/cli/record_test.py +67 -0
  58. stoobly_agent/test/mock_data/lifecycle_hooks.py +35 -0
  59. {stoobly_agent-1.4.1.dist-info → stoobly_agent-1.5.0.dist-info}/LICENSE +1 -1
  60. {stoobly_agent-1.4.1.dist-info → stoobly_agent-1.5.0.dist-info}/METADATA +7 -12
  61. {stoobly_agent-1.4.1.dist-info → stoobly_agent-1.5.0.dist-info}/RECORD +65 -61
  62. /stoobly_agent/app/cli/scaffold/templates/workflow/mock/{fixtures/.keep → public/.gitignore} +0 -0
  63. /stoobly_agent/app/cli/scaffold/templates/workflow/test/{fixtures/.keep → public/.gitignore} +0 -0
  64. {stoobly_agent-1.4.1.dist-info → stoobly_agent-1.5.0.dist-info}/WHEEL +0 -0
  65. {stoobly_agent-1.4.1.dist-info → stoobly_agent-1.5.0.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,4 @@
1
+ import mimetypes
1
2
  import os
2
3
  import pdb
3
4
  import re
@@ -5,6 +6,7 @@ import re
5
6
  from io import BytesIO
6
7
  from mitmproxy.http import Request as MitmproxyRequest
7
8
  from requests import Response
9
+ from requests.structures import CaseInsensitiveDict
8
10
  from typing import Union
9
11
 
10
12
  from stoobly_agent.lib.logger import bcolors, Logger
@@ -19,45 +21,88 @@ class Options():
19
21
 
20
22
  def eval_fixtures(request: MitmproxyRequest, **options: Options) -> Union[Response, None]:
21
23
  fixture_path = None
22
- headers = {}
24
+ headers = CaseInsensitiveDict()
25
+ status_code = 200
23
26
 
24
27
  response_fixtures = options.get('response_fixtures')
25
- fixture = __eval_response_fixtures(request, response_fixtures)
28
+ fixture: dict = __eval_response_fixtures(request, response_fixtures)
26
29
 
27
30
  if not fixture:
28
31
  public_directory_path = options.get('public_directory_path')
29
32
 
30
- if public_directory_path and os.path.exists(public_directory_path):
31
- static_file_path = os.path.join(public_directory_path, request.path.lstrip('/'))
33
+ if not public_directory_path:
34
+ return
32
35
 
33
- if os.path.exists(static_file_path):
34
- fixture_path = static_file_path
36
+ request_path = 'index' if request.path == '/' else request.path
37
+ _fixture_path = os.path.join(public_directory_path, request_path.lstrip('/'))
38
+ if request.headers.get('accept'):
39
+ fixture_path = __guess_file_path(_fixture_path, request.headers['accept'])
40
+
41
+ if not fixture_path:
42
+ fixture_path = _fixture_path
43
+ if not os.path.isfile(fixture_path):
44
+ return
35
45
  else:
36
46
  fixture_path = fixture.get('path')
37
- headers = fixture.get('headers') or {}
47
+ if not fixture_path or not os.path.isfile(fixture_path):
48
+ return
38
49
 
39
- if not fixture_path:
40
- return
50
+ _headers = fixture.get('headers')
51
+ headers = CaseInsensitiveDict(_headers if isinstance(_headers, dict) else {})
52
+
53
+ if fixture.get('status_code'):
54
+ status_code = fixture.get('status_code')
41
55
 
42
56
  with open(fixture_path, 'rb') as fp:
43
57
  response = Response()
44
58
 
45
- response.status_code = 200
59
+ response.status_code = status_code
46
60
  response.raw = BytesIO(fp.read())
47
61
  response.headers = headers
48
62
 
63
+ if not response.headers.get('content-type'):
64
+ content_type = __guess_content_type(fixture_path)
65
+ if content_type:
66
+ response.headers['content-type'] = content_type
67
+
49
68
  Logger.instance(LOG_ID).debug(f"{bcolors.OKBLUE}Resolved{bcolors.ENDC} fixture {fixture_path}")
50
69
 
51
70
  return response
52
71
 
72
+ def __guess_content_type(file_path):
73
+ file_extension = os.path.splitext(file_path)[1]
74
+ if not file_extension:
75
+ return
76
+ return mimetypes.types_map.get(file_extension)
77
+
78
+ def __guess_file_path(file_path, content_type):
79
+ file_extension = os.path.splitext(file_path)[1]
80
+ if file_extension:
81
+ return file_path
82
+
83
+ if not content_type:
84
+ return
85
+
86
+ content_types = __parse_accept_header(content_type)
87
+
88
+ for content_type in content_types:
89
+ file_extension = mimetypes.guess_extension(content_type)
90
+
91
+ if not file_extension:
92
+ continue
93
+
94
+ _file_path = f"{file_path}{file_extension}"
95
+ if os.path.isfile(_file_path):
96
+ return _file_path
97
+
53
98
  def __eval_response_fixtures(request: MitmproxyRequest, response_fixtures: Fixtures):
54
- if not response_fixtures:
99
+ if not isinstance(response_fixtures, dict):
55
100
  return
56
101
 
57
102
  method = request.method
58
103
  routes = response_fixtures.get(method)
59
104
 
60
- if not routes:
105
+ if not isinstance(routes, dict):
61
106
  return
62
107
 
63
108
  for path_pattern in routes:
@@ -65,7 +110,26 @@ def __eval_response_fixtures(request: MitmproxyRequest, response_fixtures: Fixtu
65
110
  continue
66
111
 
67
112
  fixture = routes[path_pattern]
113
+ if not isinstance(fixture, dict):
114
+ continue
115
+
68
116
  path = fixture.get('path')
69
117
 
70
- if path and os.path.exists(path):
71
- return fixture
118
+ if path:
119
+ return fixture
120
+
121
+ def __parse_accept_header(accept_header):
122
+ types = []
123
+ for item in accept_header.split(","):
124
+ parts = item.split(";")
125
+ content_type = parts[0].strip()
126
+ q_value = 1.0 # Default quality value
127
+ if len(parts) > 1 and parts[1].strip().startswith("q="):
128
+ try:
129
+ q_value = float(parts[1].strip()[2:])
130
+ except ValueError:
131
+ pass # Keep default q_value if parsing fails
132
+ types.append((content_type, q_value))
133
+
134
+ # Sort by quality factor in descending order
135
+ return [content_type for content_type, _ in sorted(types, key=lambda x: x[1], reverse=True)]
@@ -149,9 +149,9 @@ def __filter_by_match_rules(request: MitmproxyRequest, match_rules: List[MatchRu
149
149
  method = request.method.upper()
150
150
 
151
151
  keep = {
152
- request_component.BODY_PARAM: True,
152
+ request_component.BODY_PARAM: False,
153
153
  request_component.HEADER: False,
154
- request_component.QUERY_PARAM: True,
154
+ request_component.QUERY_PARAM: False,
155
155
  }
156
156
 
157
157
  match_rules = list(filter(lambda rule: method in rule.methods, match_rules))
@@ -1,10 +1,14 @@
1
1
  import pdb
2
+
2
3
  from mitmproxy.http import HTTPFlow as MitmproxyHTTPFlow
4
+ from typing import List
3
5
 
4
6
  from stoobly_agent.app.proxy.intercept_settings import InterceptSettings
7
+ from stoobly_agent.app.settings.rewrite_rule import RewriteRule
5
8
 
6
9
  from ..mitmproxy.request_facade import MitmproxyRequestFacade
7
10
  from ..mitmproxy.response_facade import MitmproxyResponseFacade
11
+ from ..utils.rewrite import rewrite_request_response
8
12
  from .joined_request import JoinedRequest
9
13
  from .proxy_request import ProxyRequest
10
14
 
@@ -18,15 +22,10 @@ def join_request(
18
22
  # Create JoinedRequest
19
23
  return JoinedRequest(proxy_request).with_response(adapted_response)
20
24
 
21
- def join_rewritten_request(flow: MitmproxyHTTPFlow, intercept_settings: InterceptSettings) -> JoinedRequest:
22
- # Adapt flow.request
25
+ def join_request_from_flow(
26
+ flow: MitmproxyHTTPFlow, intercept_settings: InterceptSettings
27
+ ) -> JoinedRequest:
23
28
  request = MitmproxyRequestFacade(flow.request)
24
-
25
- # Adapt flow.response
26
29
  response = MitmproxyResponseFacade(flow.response)
27
- rewrite_rules = intercept_settings.record_rewrite_rules
28
-
29
- request.with_parameter_rules(rewrite_rules).with_url_rules(rewrite_rules).rewrite()
30
- response.with_parameter_rules(rewrite_rules, request).rewrite()
31
30
 
32
31
  return join_request(request, response, intercept_settings)
@@ -17,7 +17,7 @@ from stoobly_agent.lib.logger import Logger, bcolors
17
17
  from stoobly_agent.lib.orm.request import Request
18
18
 
19
19
  from ..utils.publish_change_service import publish_requests_modified
20
- from .join_request_service import join_rewritten_request
20
+ from .join_request_service import join_request_from_flow
21
21
 
22
22
  AGENT_STATUSES = {
23
23
  'REQUESTS_MODIFIED': 'requests-modified'
@@ -50,7 +50,7 @@ def upload_request(
50
50
  ):
51
51
  Logger.instance(LOG_ID).info(f"{bcolors.OKCYAN}Recording{bcolors.ENDC} {flow.request.url}")
52
52
 
53
- joined_request = join_rewritten_request(flow, intercept_settings)
53
+ joined_request = join_request_from_flow(flow, intercept_settings)
54
54
 
55
55
  project_key = intercept_settings.project_key
56
56
  scenario_key = intercept_settings.scenario_key
@@ -32,6 +32,7 @@ class ReplayRequestOptions(TypedDict):
32
32
  report_key: Union[str, None]
33
33
  request_origin: Union[request_origin.CLI, None]
34
34
  response_fixtures_path: str
35
+ response_mode: Union[mode.RECORD, None]
35
36
  scenario_key: Union[str, None]
36
37
  scheme: str
37
38
  test_filter: test_filter.TestFilter
@@ -83,6 +84,9 @@ def replay(context: ReplayContext, options: ReplayRequestOptions) -> requests.Re
83
84
  if options.get('response_fixtures_path'):
84
85
  __handle_path_header(custom_headers.RESPONSE_FIXTURES_PATH, options['response_fixtures_path'], headers)
85
86
 
87
+ if options.get('response_mode'):
88
+ headers[custom_headers.RESPONSE_PROXY_MODE] = options['response_mode']
89
+
86
90
  if options.get('scenario_key'):
87
91
  headers[custom_headers.SCENARIO_KEY] = options['scenario_key']
88
92
 
@@ -173,10 +177,6 @@ def __handle_mode_option(_mode, request: Request, headers):
173
177
  headers[custom_headers.MOCK_REQUEST_ID] = str(request.id)
174
178
 
175
179
  headers[custom_headers.MOCK_POLICY] = mock_policy.ALL
176
- elif _mode == mode.RECORD:
177
- # If recording, then it's actually a replay and record
178
- headers[custom_headers.PROXY_MODE] = mode.REPLAY
179
- headers[custom_headers.RESPONSE_PROXY_MODE] = mode.RECORD
180
180
 
181
181
  def __create_replayed_response(request_id: int, res: requests.Response, latency: int):
182
182
  replayed_response_model = ReplayedResponseModel(Settings.instance())
@@ -11,7 +11,7 @@ from stoobly_agent.lib.logger import Logger, bcolors
11
11
  from stoobly_agent.app.proxy.intercept_settings import InterceptSettings
12
12
 
13
13
  from ...intercept_settings import InterceptSettings
14
- from ...record.join_request_service import join_rewritten_request
14
+ from ...record.join_request_service import join_request_from_flow
15
15
 
16
16
  LOG_ID = 'Test'
17
17
 
@@ -43,7 +43,7 @@ def upload_test(
43
43
  flow: MitmproxyHTTPFlow,
44
44
  **kwargs: UploadTestData
45
45
  ) -> Response:
46
- joined_request = join_rewritten_request(flow, intercept_settings)
46
+ joined_request = join_request_from_flow(flow, intercept_settings)
47
47
 
48
48
  Logger.instance(LOG_ID).info(f"{bcolors.OKCYAN}Uploading{bcolors.ENDC} test results for {joined_request.proxy_request.url()}")
49
49
 
@@ -6,7 +6,7 @@ from typing import List
6
6
 
7
7
  from stoobly_agent.app.proxy.intercept_settings import InterceptSettings
8
8
  from stoobly_agent.app.settings.firewall_rule import FirewallRule
9
- from stoobly_agent.config.constants import mock_policy, request_origin
9
+ from stoobly_agent.config.constants import intercept_policy, request_origin
10
10
  from stoobly_agent.lib.logger import bcolors, Logger
11
11
 
12
12
  LOG_ID = 'Firewall'
@@ -18,8 +18,8 @@ def get_active_mode_policy(request: MitmproxyRequest, intercept_settings: Interc
18
18
  if allowed_request(request, intercept_settings):
19
19
  return intercept_settings.policy
20
20
  else:
21
- # If the request path does not match accepted paths, do not mock
22
- return mock_policy.NONE
21
+ # If the request path does not match accepted paths, do not intercept
22
+ return intercept_policy.NONE
23
23
 
24
24
  def allowed_request(request: MitmproxyRequest, intercept_settings: InterceptSettings) -> bool:
25
25
  # If an exclude rule(s) exists, then only requests not matching these pattern(s) are allowed
@@ -36,8 +36,6 @@ def bad_request(flow: MitmproxyHTTPFlow, message: str):
36
36
  {'Content-Type': 'text/plain'} # (optional) headers
37
37
  )
38
38
 
39
- return False
40
-
41
39
  # Without deleting this header, causes parsing issues when reading response
42
40
  def disable_transfer_encoding(response: MitmproxyResponse) -> None:
43
41
  header_name = 'Transfer-Encoding'
@@ -0,0 +1,72 @@
1
+ from mitmproxy.http import HTTPFlow as MitmproxyHTTPFlow
2
+ from typing import List
3
+
4
+ from stoobly_agent.app.settings.constants.request_component import REQUEST_COMPONENTS, RESPONSE_COMPONENTS
5
+ from stoobly_agent.app.settings.rewrite_rule import RewriteRule
6
+
7
+ from ..mitmproxy.request_facade import MitmproxyRequestFacade
8
+ from ..mitmproxy.response_facade import MitmproxyResponseFacade
9
+
10
+ def select_request_rewrite_rules(rewrite_rules: List[RewriteRule]):
11
+ rules = []
12
+
13
+ for rewrite_rule in rewrite_rules:
14
+ parameter_rules = list(filter(
15
+ lambda parameter: parameter.type in REQUEST_COMPONENTS and parameter.name,
16
+ rewrite_rule.parameter_rules or []
17
+ ))
18
+
19
+ if len(parameter_rules) > 0:
20
+ rewrite_rule = RewriteRule(rewrite_rule.to_dict())
21
+ rewrite_rule.url_rules = rewrite_rule.url_rules
22
+ rewrite_rule.parameter_rules = parameter_rules
23
+ rules.append(rewrite_rule)
24
+
25
+ return rules
26
+
27
+ def select_response_rewrite_rules(rewrite_rules: List[RewriteRule]):
28
+ rules = []
29
+
30
+ for rewrite_rule in rewrite_rules:
31
+ parameter_rules = list(filter(
32
+ lambda parameter: parameter.type in RESPONSE_COMPONENTS and parameter.name,
33
+ rewrite_rule.parameter_rules or []
34
+ ))
35
+
36
+ if len(parameter_rules) > 0:
37
+ rewrite_rule = RewriteRule(rewrite_rule.to_dict())
38
+ rewrite_rule.url_rules = []
39
+ rewrite_rule.parameter_rules = parameter_rules
40
+ rules.append(rewrite_rule)
41
+
42
+ return rules
43
+
44
+ def rewrite_request_response(flow: MitmproxyHTTPFlow, rewrite_rules: List[RewriteRule]):
45
+ request = rewrite_request(flow, rewrite_rules)
46
+ response = rewrite_response(flow, rewrite_rules, request)
47
+ return request, response
48
+
49
+ def rewrite_request(flow: MitmproxyHTTPFlow, rewrite_rules: List[RewriteRule]):
50
+ request = None
51
+
52
+ # Adapt flow.request
53
+ request = MitmproxyRequestFacade(flow.request)
54
+
55
+ _rewrite_rules = select_request_rewrite_rules(rewrite_rules)
56
+ if len(_rewrite_rules):
57
+ request.with_parameter_rules(_rewrite_rules).with_url_rules(_rewrite_rules).rewrite()
58
+
59
+ return request
60
+
61
+ def rewrite_response(flow: MitmproxyHTTPFlow, rewrite_rules: List[RewriteRule], request = None):
62
+ # Adapt flow.request
63
+ request = request or MitmproxyRequestFacade(flow.request)
64
+
65
+ # Adapt flow.response
66
+ response = MitmproxyResponseFacade(flow.response)
67
+
68
+ _rewrite_rules = select_response_rewrite_rules(rewrite_rules)
69
+ if len(_rewrite_rules):
70
+ response.with_parameter_rules(_rewrite_rules, request).rewrite()
71
+
72
+ return response
@@ -6,4 +6,7 @@ QUERY_PARAM = 'Query Param'
6
6
  RESPONSE_HEADER = 'Response Header'
7
7
  RESPONSE_PARAM = 'Response Param'
8
8
 
9
- RequestComponent = Literal[BODY_PARAM, HEADER, QUERY_PARAM, RESPONSE_HEADER, RESPONSE_PARAM]
9
+ REQUEST_COMPONENTS = [BODY_PARAM, HEADER, QUERY_PARAM]
10
+ RESPONSE_COMPONENTS = [RESPONSE_HEADER, RESPONSE_PARAM]
11
+
12
+ RequestComponent = Literal[BODY_PARAM, HEADER, QUERY_PARAM, RESPONSE_HEADER, RESPONSE_PARAM]
stoobly_agent/cli.py CHANGED
@@ -159,6 +159,7 @@ def run(**kwargs):
159
159
  @click.option('--format', type=click.Choice([RAW_FORMAT]), help='Format response')
160
160
  @click.option('-H', '--header', multiple=True, help='Pass custom header(s) to server')
161
161
  @click.option('--lifecycle-hooks-path', help='Path to lifecycle hooks script.')
162
+ @click.option('-o', '--output', help='Write to file instead of stdout')
162
163
  @ConditionalDecorator(lambda f: click.option('--project-key')(f), is_remote)
163
164
  @click.option('--public-directory-path', help='Path to public files. Used for mocking requests.')
164
165
  @click.option('--response-fixtures-path', help='Path to response fixtures yaml. Used for mocking requests.')
@@ -169,16 +170,7 @@ def mock(**kwargs):
169
170
  if kwargs.get('remote_project_key'):
170
171
  validate_project_key(kwargs['remote_project_key'])
171
172
 
172
- if kwargs.get('scenario_key'):
173
- validate_scenario_key(kwargs['scenario_key'])
174
-
175
- request = __build_request_from_curl(**kwargs)
176
-
177
- context = ReplayContext.from_python_request(request)
178
- response: requests.Response = replay_request(context, {
179
- **kwargs,
180
- 'mode': mode.MOCK,
181
- })
173
+ response = __replay(mode.MOCK, **kwargs)
182
174
 
183
175
  if response.status_code == custom_response_codes.NOT_FOUND:
184
176
  content = response.content
@@ -186,10 +178,15 @@ def mock(**kwargs):
186
178
  sys.exit(1)
187
179
 
188
180
  if kwargs['format'] == RAW_FORMAT:
189
- print_raw_response(response)
181
+ print_raw_response(response, kwargs['output'])
190
182
  else:
191
183
  content = response.content
192
- print(decode(content), end='')
184
+
185
+ if not kwargs['output']:
186
+ print(decode(content), end='')
187
+ else:
188
+ with open(kwargs['output'], 'w') as fp:
189
+ fp.write(decode(content))
193
190
 
194
191
  @main.command(
195
192
  help="Record request"
@@ -197,30 +194,28 @@ def mock(**kwargs):
197
194
  @click.option('-d', '--data', default='', help='HTTP POST data')
198
195
  @click.option('--format', type=click.Choice([RAW_FORMAT]), help='Format response')
199
196
  @click.option('-H', '--header', multiple=True, help='Pass custom header(s) to server')
197
+ @click.option('--lifecycle-hooks-path', help='Path to lifecycle hooks script.')
198
+ @click.option('-o', '--output', help='Write to file instead of stdout')
200
199
  @ConditionalDecorator(lambda f: click.option('--project-key')(f), is_remote)
201
200
  @click.option('-X', '--request', default='GET', help='Specify request command to use')
202
201
  @click.option('--scenario-key')
203
202
  @click.argument('url')
204
203
  def record(**kwargs):
205
- if kwargs.get('scenario_key'):
206
- validate_scenario_key(kwargs['scenario_key'])
207
-
208
- request = __build_request_from_curl(**kwargs)
209
-
210
- context = ReplayContext.from_python_request(request)
211
- response: requests.Response = replay_request(context, {
212
- **kwargs,
213
- 'mode': mode.RECORD,
214
- })
204
+ response = __replay(mode.RECORD, **kwargs)
215
205
 
216
206
  if kwargs['format'] == RAW_FORMAT:
217
- print_raw_response(response)
207
+ print_raw_response(response, kwargs['output'])
218
208
  else:
219
- try:
220
- content = response.raw.data
221
- print(content.decode(json.detect_encoding(content)), end='')
222
- except UnicodeDecodeError:
223
- print('Warning: Binary output can mess up your terminal.')
209
+ content: bytes = response.raw.data
210
+
211
+ if not kwargs['output']:
212
+ try:
213
+ print(content.decode(json.detect_encoding(content)), end='')
214
+ except UnicodeDecodeError:
215
+ print('Warning: Binary output can mess up your terminal.')
216
+ else:
217
+ with open(kwargs['output'], 'w') as fp:
218
+ fp.write(content.decode(json.detect_encoding(content)))
224
219
 
225
220
  def __build_request_from_curl(**kwargs):
226
221
  headers = {}
@@ -238,3 +233,15 @@ def __build_request_from_curl(**kwargs):
238
233
  method=kwargs['request'],
239
234
  url=kwargs['url']
240
235
  )
236
+
237
+ def __replay(mode, **kwargs):
238
+ if kwargs.get('scenario_key'):
239
+ validate_scenario_key(kwargs['scenario_key'])
240
+
241
+ request = __build_request_from_curl(**kwargs)
242
+
243
+ context = ReplayContext.from_python_request(request)
244
+ return replay_request(context, {
245
+ **kwargs,
246
+ 'mode': mode,
247
+ })
@@ -0,0 +1,2 @@
1
+ ALL = 'all'
2
+ NONE = 'none'
@@ -1,3 +1,5 @@
1
- ALL = 'all'
2
- NONE = 'none'
1
+ from .intercept_policy import ALL as INTERCEPT_ALL, NONE as INTERCEPT_NONE
2
+
3
+ ALL = INTERCEPT_ALL
4
+ NONE = INTERCEPT_NONE
3
5
  FOUND = 'found'
@@ -1,5 +1,7 @@
1
- ALL = 'all'
1
+ from .intercept_policy import ALL as INTERCEPT_ALL, NONE as INTERCEPT_NONE
2
+
3
+ ALL = INTERCEPT_ALL
2
4
  FOUND = 'found'
3
- NONE = 'none'
5
+ NONE = INTERCEPT_NONE
4
6
  NOT_FOUND = 'not_found'
5
7
  OVERWRITE = 'overwrite'
@@ -1,2 +1,4 @@
1
- ALL = 'all'
2
- NONE = 'none'
1
+ from .intercept_policy import ALL as INTERCEPT_ALL, NONE as INTERCEPT_NONE
2
+
3
+ ALL = INTERCEPT_ALL
4
+ NONE = INTERCEPT_NONE