Skip to content

Commit c49d631

Browse files
J3utterJ3utter
andauthored
639 openapi specification path option (#640)
* Create search_open_api_endpoint service * Add --openapi-specification-path option * Apply option to eval request service * Refactor * Fix cli_mock_integration_test.py * Address PR comment * Fix stoobly_agent/test/app/cli/cli_mock_integration_test.py when running all tests --------- Co-authored-by: J3utter <j3utter@gmail.com>
1 parent d833409 commit c49d631

15 files changed

Lines changed: 504 additions & 31 deletions

stoobly_agent/app/cli/endpoint_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def endpoint(ctx):
2828
)
2929
@click.option('--lifecycle-hooks-path', help='Path to lifecycle hooks script.')
3030
@ConditionalDecorator(lambda f: click.option('--project-key', help='Project to create endpoint to.')(f), is_remote)
31-
@ConditionalDecorator(lambda f: click.option('--remote-project-key', help='Which remote project to apply endpoints from.')(f), is_remote and is_local)
31+
@ConditionalDecorator(lambda f: click.option('--remote-project-key', help='Which remote project to apply endpoints from.')(f), is_local and is_remote)
3232
@click.option('--scenario-key', help='Which scenario requests to apply the endpoint to. If none, then the endpoint will be applied to all requests.')
3333
@click.option('--source-format', required=True, type=click.Choice([OPENAPI_FORMAT]), help='Spec file format.')
3434
@click.option('--source-path', help='Path to spec file.')

stoobly_agent/app/cli/request_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ def snapshot_list(**kwargs):
193193
'''
194194
)
195195
@click.option('--public-dir-path', help='Path to public files. Used for mocking requests.')
196-
@ConditionalDecorator(lambda f: click.option('--remote-project-key', help='Use remote project for endpoint definitions.')(f), is_remote and is_local)
196+
@ConditionalDecorator(lambda f: click.option('--remote-project-key', help='Use remote project for endpoint definitions.')(f), is_local and is_remote)
197197
@ConditionalDecorator(lambda f: click.option('--report-key', help='Save to report.')(f), is_remote)
198198
@click.option('--response-fixtures-path', help='Path to response fixtures yaml. Used for mocking requests.')
199199
@ConditionalDecorator(lambda f: click.option('--save', is_flag=True, default=False, help='Saves test results.')(f), is_remote)

stoobly_agent/app/cli/scenario_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ def snapshot_list(**kwargs):
214214
'''
215215
)
216216
@click.option('--public-dir-path', help='Path to public files. Used for mocking requests.')
217-
@ConditionalDecorator(lambda f: click.option('--remote-project-key', help='Use remote project for endpoint definitions.')(f), is_remote)
217+
@ConditionalDecorator(lambda f: click.option('--remote-project-key', help='Use remote project for endpoint definitions.')(f), is_local and is_remote)
218218
@ConditionalDecorator(lambda f: click.option('--report-key', help='Save results to report.')(f), is_remote)
219219
@click.option('--response-fixtures-path', help='Path to response fixtures yaml. Used for mocking requests.')
220220
@ConditionalDecorator(lambda f: click.option('--save', is_flag=True, default=False, help='Save results.')(f), is_remote)

stoobly_agent/app/proxy/intercept_settings.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from mitmproxy.http import Request as MitmproxyRequest
1212
from .mock.types import LifecycleHooksPath
1313

14+
from stoobly_agent.app.cli.helpers.feature_flags import remote
1415
from stoobly_agent.app.settings.constants import firewall_action, intercept_mode
1516
from stoobly_agent.app.settings.parameter_rule import ParameterRule as ParameterRuleClass
1617
from stoobly_agent.app.settings.rewrite_rule import RewriteRule
@@ -89,6 +90,10 @@ def active(self):
8990
return self.__headers[custom_headers.INTERCEPT_ACTIVE] == '1'
9091

9192
return self.__intercept_settings.active
93+
94+
@property
95+
def is_remote(self):
96+
return remote(self.__settings)
9297

9398
@property
9499
def lifecycle_hooks_path(self):
@@ -211,7 +216,25 @@ def response_fixtures_path(self):
211216
return self.__headers[custom_headers.RESPONSE_FIXTURES_PATH]
212217

213218
if os.environ.get(env_vars.AGENT_RESPONSE_FIXTURES_PATH):
214-
return os.environ[env_vars.AGENT_RESPONSE_FIXTURES_PATH]
219+
return os.environ[env_vars.AGENT_RESPONSE_FIXTURES_PATH]
220+
221+
@property
222+
def openapi_specification_path(self):
223+
"""Filesystem path to an OpenAPI spec; from header or environment variable only."""
224+
raw_value = None
225+
if self.__headers and custom_headers.OPENAPI_SPECIFICATION_PATH in self.__headers:
226+
raw_value = self.__headers[custom_headers.OPENAPI_SPECIFICATION_PATH]
227+
elif os.environ.get(env_vars.AGENT_OPENAPI_SPECIFICATION_PATH):
228+
raw_value = os.environ[env_vars.AGENT_OPENAPI_SPECIFICATION_PATH]
229+
else:
230+
return None
231+
232+
if isinstance(raw_value, str):
233+
normalized = raw_value.strip()
234+
else:
235+
normalized = str(raw_value).strip() if raw_value is not None else ''
236+
237+
return normalized if normalized else None
215238

216239
@property
217240
def parsed_remote_project_key(self):

stoobly_agent/app/proxy/mock/eval_request_service.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import json
2-
import pdb
32
import re
43

54
from typing import TYPE_CHECKING, List, TypedDict, Union
65

6+
from stoobly_agent.app.proxy.mock.search_open_api_endpoint import inject_search_open_api_endpoint
7+
78
if TYPE_CHECKING:
89
from mitmproxy.http import Request as MitmproxyRequest
910
from requests import Response
@@ -32,16 +33,16 @@ class EvalRequestOptions(TypedDict):
3233
retry: int
3334

3435
def inject_eval_request(
35-
request_model: RequestModel,
36-
intercept_settings: InterceptSettings,
36+
request_model: Union[RequestModel, None],
37+
intercept_settings: Union[InterceptSettings, None],
3738
):
3839
settings = Settings.instance()
3940

4041
if not request_model:
4142
request_model = RequestModel(settings)
4243

4344
if not intercept_settings:
44-
intercept_settings = InterceptSettings(intercept_settings)
45+
intercept_settings = InterceptSettings(settings)
4546

4647
return lambda request, ignored_components, **options: eval_request(
4748
request_model, intercept_settings, request, ignored_components or [], **options
@@ -77,13 +78,22 @@ def eval_request(
7778

7879
# Tease out API returning ignored components on custom not found
7980
if request_model.is_local:
80-
remote_project_key = intercept_settings.parsed_remote_project_key
81-
82-
if remote_project_key:
83-
search_endpoint = inject_search_endpoint(intercept_settings)
84-
remote_project_id = remote_project_key.id
85-
endpoint_promise = lambda: search_endpoint(remote_project_id, request.method, request.url, ignored_components=1)
86-
81+
endpoint_promise = None
82+
83+
if intercept_settings.is_remote:
84+
remote_project_key = intercept_settings.parsed_remote_project_key
85+
if remote_project_key:
86+
search_endpoint = inject_search_endpoint(intercept_settings)
87+
remote_project_id = remote_project_key.id
88+
endpoint_promise = lambda: search_endpoint(remote_project_id, request.method, request.url, ignored_components=1)
89+
90+
if not endpoint_promise:
91+
openapi_specification_path = intercept_settings.openapi_specification_path
92+
if openapi_specification_path:
93+
search_endpoint = inject_search_open_api_endpoint(intercept_settings)
94+
endpoint_promise = lambda: search_endpoint(request.method, request.url, ignored_components=1)
95+
96+
if endpoint_promise:
8797
query_params_builder.with_param(request_query_params.ENDPOINT_PROMISE, endpoint_promise)
8898

8999
# Only trigger DB-side recomputation when matching with ignored components.

stoobly_agent/app/proxy/mock/hashed_request_decorator.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
'PATH_SEGMENT': 2,
1515
'QUERY_PARAM': 3,
1616
'BODY_PARAM': 4,
17-
'RESPONSE': 5
17+
'RESPONSE': 5,
18+
'BODY': 6,
19+
'RESPONSE_HEADER': 7,
1820
}
1921

2022
LOG_ID = 'HashedRequest'
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import logging
2+
import os
3+
import re
4+
from typing import Dict, List, Optional
5+
from urllib.parse import urlparse
6+
7+
from stoobly_agent.app.cli.helpers.openapi_endpoint_adapter import OpenApiEndpointAdapter
8+
from stoobly_agent.app.proxy.intercept_settings import InterceptSettings
9+
from stoobly_agent.app.proxy.mock.hashed_request_decorator import COMPONENT_TYPES
10+
from stoobly_agent.lib.api.interfaces.endpoints import EndpointShowResponse, IgnoredComponent
11+
12+
logger = logging.getLogger(__name__)
13+
14+
class OpenApiEndpointCache:
15+
"""Lazy cache of parsed endpoints keyed by resolved absolute spec path."""
16+
17+
def __init__(self):
18+
self._by_path: Dict[str, List[EndpointShowResponse]] = {}
19+
20+
def endpoints_for(self, open_api_spec: str) -> List[EndpointShowResponse]:
21+
key = _normalize_open_api_spec_path(open_api_spec)
22+
if key not in self._by_path:
23+
self._by_path[key] = load_openapi_endpoints_from_file(key)
24+
return self._by_path[key]
25+
26+
27+
_endpoint_cache = OpenApiEndpointCache()
28+
29+
def load_openapi_endpoints_from_file(open_api_spec: str) -> List[EndpointShowResponse]:
30+
"""
31+
Parse an OpenAPI spec file into a list of endpoint show responses.
32+
"""
33+
try:
34+
return OpenApiEndpointAdapter().adapt_from_file(open_api_spec)
35+
except Exception as e:
36+
logger.warning("Failed to load OpenAPI spec %s: %s", open_api_spec, e)
37+
return []
38+
39+
40+
def sql_like(value: str, pattern: str) -> bool:
41+
"""
42+
SQLite LIKE semantics for ASCII: % = any sequence, _ = single character.
43+
"""
44+
parts: List[str] = []
45+
for c in pattern:
46+
if c == "%":
47+
parts.append(".*")
48+
elif c == "_":
49+
parts.append(".")
50+
else:
51+
parts.append(re.escape(c))
52+
regex = "".join(parts)
53+
return re.fullmatch(regex, value, flags=re.DOTALL) is not None
54+
55+
56+
def _normalize_open_api_spec_path(open_api_spec: str) -> str:
57+
return os.path.abspath(os.path.normpath(open_api_spec))
58+
59+
def _request_port_str(uri) -> str:
60+
if uri.port is not None:
61+
return str(uri.port)
62+
if uri.scheme == "https":
63+
return "443"
64+
if uri.scheme == "http":
65+
return "80"
66+
return "0"
67+
68+
69+
def _endpoint_hostname_from_netloc(host_field: str) -> Optional[str]:
70+
"""
71+
OpenApiEndpointAdapter stores server netloc in host (e.g. 'localhost:80', 'petstore.swagger.io').
72+
Requests use urlparse().hostname without port when the URL omits :port.
73+
"""
74+
if "://" in host_field:
75+
parsed = urlparse(host_field)
76+
else:
77+
parsed = urlparse("http://" + host_field)
78+
return parsed.hostname.lower() if parsed.hostname else None
79+
80+
81+
def _host_matches(endpoint: EndpointShowResponse, request_hostname: Optional[str]) -> bool:
82+
host = endpoint.get("host") or ""
83+
if not host or host == "%":
84+
return True
85+
if host == "-":
86+
return True
87+
if not request_hostname:
88+
return False
89+
ep_hostname = _endpoint_hostname_from_netloc(host)
90+
if ep_hostname is None:
91+
return True
92+
return ep_hostname == request_hostname.lower()
93+
94+
95+
def _port_matches(endpoint: EndpointShowResponse, request_port: str) -> bool:
96+
port = endpoint.get("port") or ""
97+
if not port or port == "%":
98+
return True
99+
return port == request_port
100+
101+
102+
def _path_matches(endpoint: EndpointShowResponse, request_path: str) -> bool:
103+
match_pattern = endpoint.get("match_pattern") or ""
104+
like_pattern = f"%{match_pattern}"
105+
return sql_like(request_path, like_pattern)
106+
107+
108+
def _find_matching_endpoint(
109+
endpoints: List[EndpointShowResponse],
110+
method: str,
111+
url: str,
112+
) -> Optional[EndpointShowResponse]:
113+
uri = urlparse(url)
114+
request_path = uri.path or ""
115+
request_path = request_path.rstrip("/") or "/"
116+
request_hostname = uri.hostname
117+
request_port = _request_port_str(uri)
118+
method_u = method.upper()
119+
120+
for endpoint in endpoints:
121+
if endpoint.get("method", "").upper() != method_u:
122+
continue
123+
if not _host_matches(endpoint, request_hostname):
124+
continue
125+
if not _port_matches(endpoint, request_port):
126+
continue
127+
if not _path_matches(endpoint, request_path):
128+
continue
129+
return endpoint
130+
131+
return None
132+
133+
134+
def _component_matches_ignoreable_ignored(component: dict) -> bool:
135+
"""
136+
Mirrors stoobly-api Ignoreable#ignored?: !is_deterministic || !is_required
137+
(see app/models/endpoint.rb ignored_components and app/models/concerns/ignoreable.rb).
138+
"""
139+
is_deterministic = component.get("is_deterministic", True)
140+
is_required = component.get("is_required", True)
141+
return (not is_deterministic) or (not is_required)
142+
143+
144+
def build_ignored_components_from_openapi_endpoint(endpoint: EndpointShowResponse) -> List[IgnoredComponent]:
145+
"""
146+
Mirrors Endpoint#ignored_components: query/header/body/response_header names where ignored?,
147+
serialized like endpoints/_ignored_component.json.jbuilder (name, query, type).
148+
"""
149+
out: List[IgnoredComponent] = []
150+
151+
for row in endpoint.get("query_param_names") or []:
152+
if _component_matches_ignoreable_ignored(row):
153+
out.append(
154+
{
155+
"name": row["name"],
156+
"query": row["name"],
157+
"type": COMPONENT_TYPES["QUERY_PARAM"],
158+
}
159+
)
160+
161+
for row in endpoint.get("header_names") or []:
162+
if _component_matches_ignoreable_ignored(row):
163+
out.append(
164+
{
165+
"name": row["name"],
166+
"query": row["name"],
167+
"type": COMPONENT_TYPES["HEADER"],
168+
}
169+
)
170+
171+
for row in endpoint.get("body_param_names") or []:
172+
if _component_matches_ignoreable_ignored(row):
173+
out.append(
174+
{
175+
"name": row["name"],
176+
"query": row["query"],
177+
"type": COMPONENT_TYPES["BODY_PARAM"],
178+
}
179+
)
180+
181+
for row in endpoint.get("response_header_names") or []:
182+
if _component_matches_ignoreable_ignored(row):
183+
out.append(
184+
{
185+
"name": row["name"],
186+
"query": row["name"],
187+
"type": COMPONENT_TYPES["RESPONSE_HEADER"],
188+
}
189+
)
190+
191+
return out
192+
193+
194+
def search_open_api_endpoint(
195+
open_api_spec: str,
196+
method: str,
197+
url: str,
198+
**_query_params,
199+
) -> Optional[EndpointShowResponse]:
200+
endpoints = _endpoint_cache.endpoints_for(open_api_spec)
201+
if not endpoints:
202+
return None
203+
ep = _find_matching_endpoint(endpoints, method, url)
204+
if ep is None:
205+
return None
206+
merged: EndpointShowResponse = dict(ep)
207+
merged["ignored_components"] = build_ignored_components_from_openapi_endpoint(ep)
208+
return merged
209+
210+
211+
def inject_search_open_api_endpoint(intercept_settings: InterceptSettings):
212+
def _search(method: str, url: str, **_query_params):
213+
open_api_spec = intercept_settings.openapi_specification_path
214+
if not open_api_spec:
215+
return None
216+
return search_open_api_endpoint(open_api_spec, method, url, **_query_params)
217+
218+
return _search

stoobly_agent/app/proxy/run.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ def __filter_options(options):
141141
if 'public_dir_path' in options:
142142
del options['public_dir_path']
143143

144+
if 'remote_project_key' in options:
145+
del options['remote_project_key']
146+
144147
if 'response_fixtures_path' in options:
145148
del options['response_fixtures_path']
146149

@@ -151,10 +154,10 @@ def __filter_options(options):
151154
del options['ui_port']
152155

153156
del options['log_level']
157+
del options['openapi_specification_path']
154158
del options['proxy_host']
155159
del options['proxy_mode']
156160
del options['proxy_port']
157-
del options['remote_project_key']
158161
del options['request_log_enable']
159162
del options['request_log_level']
160163
del options['request_log_append']

0 commit comments

Comments
 (0)