Skip to content

Commit b37aa3a

Browse files
author
J3utter
committed
Merge branch 'master' of github.com:Stoobly/stoobly-agent
2 parents 188e3bd + 738ba65 commit b37aa3a

40 files changed

Lines changed: 1753 additions & 561 deletions

docs/mock-request-matching.md

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,24 @@
44

55
How an **incoming proxied request** is matched to a **recorded row** in the local DB for mocks: **hash-based** identity, optional **match rules** that drop hash dimensions, and optional **`compute`** to re-hash stored `raw` when **ignored components** change between record time and the retry path. Custom status codes: **`IGNORE_COMPONENTS = 498`**, **`NOT_FOUND = 499`** ([`custom_response_codes`](../stoobly_agent/app/proxy/constants/custom_response_codes.py)) — not standard HTTP 404/498.
66

7-
### Remote project key and `compute`
7+
### Endpoint lookup and `compute`
88

9-
When **local** + **remote project key**: **`compute='1'`** is attached only if **`retry`** and non-empty **`ignored_components`** after [`eval_request`](../stoobly_agent/app/proxy/mock/eval_request_service.py) ([`COMPUTE`](../stoobly_agent/config/constants/query_params.py)). That widens the ORM query and runs [`filter_requests_by_hashes`](../stoobly_agent/app/models/factories/resource/local_db/helpers/filter_requests_by_hashes_service.py) so stored **`raw`** is re-hashed with the same ignores as the live request.
9+
When mocking against **local DB**, [`eval_request`](../stoobly_agent/app/proxy/mock/eval_request_service.py) may attach an `endpoint_promise` from either:
10+
11+
- remote project endpoint search via [`search_endpoint`](../stoobly_agent/app/proxy/mock/search_endpoint.py), or
12+
- OpenAPI endpoint search via [`search_open_api_endpoint`](../stoobly_agent/app/proxy/mock/search_open_api_endpoint.py).
13+
14+
If `endpoint_promise` exists, and the request is on **retry** with non-empty ignored components, **`compute='1'`** is attached ([`COMPUTE`](../stoobly_agent/config/constants/query_params.py)). That widens the ORM query and runs [`filter_requests_by_hashes`](../stoobly_agent/app/models/factories/resource/local_db/helpers/filter_requests_by_hashes_service.py) so stored **`raw`** is re-hashed with the same ignores as the live request.
15+
16+
### Endpoint cache behavior (remote + OpenAPI)
17+
18+
[`endpoint_cache`](../stoobly_agent/app/proxy/mock/endpoint_cache.py) is a singleton used by both search paths. It:
19+
20+
- caches parsed OpenAPI specs by normalized absolute path,
21+
- caches remote endpoint index calls by `(project_id, index_params)`,
22+
- merges both layers into one endpoint-id map, where **latest merge wins** on ID collisions,
23+
- returns OpenAPI-derived ignored components from optional/nondeterministic fields (query/header/body/response-header),
24+
- prefetches remote endpoints from settings when remote mode is enabled.
1025

1126
### Hash dimensions
1227

@@ -22,7 +37,7 @@ When **local** + **remote project key**: **`compute='1'`** is attached only if *
2237
flowchart TB
2338
subgraph prep [Optional prep — handle_request_mock_generic]
2439
IG{ignore_rules non-empty?}
25-
IG -->|yes| MERGE[Merge rewrite → ignored_components]
40+
IG -->|yes| MERGE[Merge ignore rules → ignored_components]
2641
IG -->|no| IG0[Use ignored_components as-is]
2742
MERGE --> INIT
2843
IG0 --> INIT
@@ -42,7 +57,7 @@ flowchart TB
4257
4358
subgraph opt_retry [Options inside eval_request on retry]
4459
CP{Add compute=1?}
45-
CP -->|yes| WCOMP["compute=1: local resource AND remote project key AND len(ignored_components) > 0"]
60+
CP -->|yes| WCOMP["compute=1: local resource AND endpoint_promise AND retry AND len(ignored_components) > 0"]
4661
CP -->|no| NOCOMP[Query without compute]
4762
WCOMP --> RES2[response]
4863
NOCOMP --> RES2
@@ -87,17 +102,18 @@ flowchart TB
87102
Strip --> ORM2[where_for coarse candidates]
88103
ORM2 --> Rows2[candidates]
89104
Rows2 --> IG[ignored_components from ENDPOINT_PROMISE]
90-
IG --> Filt[filter_requests_by_hashes on raw]
91-
Filt --> CLR[clear ENDPOINT_PROMISE in query_params]
92-
CLR --> Rows3[rows]
105+
IG --> CF{ignored_components non-empty?}
106+
CF -->|yes| Filt[filter_requests_by_hashes on raw]
107+
CF -->|no| Rows3[rows]
108+
Filt --> Rows3[rows]
93109
Rows1 --> Pick[pick row or scenario tiebreak]
94110
Rows3 --> Pick
95111
Pick --> Got{row found?}
96112
Got -->|yes| OK[transform stored response]
97113
Got -->|no| NFD[no matching row]
98114
NFD --> RY{retry truthy?}
99115
RY -->|yes| R499[499 CustomNotFoundResponseBuilder]
100-
RY -->|no| EP2{endpoint_promise yields ignores?}
116+
RY -->|no| EP2{not retry AND component hashes exist AND endpoint yields ignores?}
101117
EP2 -->|yes| R498[498 IgnoreComponentsResponseBuilder]
102118
EP2 -->|no| R499
103119
```
@@ -110,6 +126,9 @@ flowchart TB
110126
|--------|----------|
111127
| Mock entry, retry, fixtures | [`handle_mock_service.py`](../stoobly_agent/app/proxy/handle_mock_service.py) |
112128
| Query / hashes / match rules / `compute` | [`eval_request_service.py`](../stoobly_agent/app/proxy/mock/eval_request_service.py) |
129+
| Endpoint cache + OpenAPI ignored-component derivation | [`endpoint_cache.py`](../stoobly_agent/app/proxy/mock/endpoint_cache.py) |
130+
| Remote endpoint search adapter | [`search_endpoint.py`](../stoobly_agent/app/proxy/mock/search_endpoint.py) |
131+
| OpenAPI endpoint search adapter | [`search_open_api_endpoint.py`](../stoobly_agent/app/proxy/mock/search_open_api_endpoint.py) |
113132
| Local DB lookup, strip columns, not found 498/499 | [`request_adapter.py`](../stoobly_agent/app/models/factories/resource/local_db/request_adapter.py) |
114133
| Candidate filtering | [`filter_requests_by_hashes_service.py`](../stoobly_agent/app/models/factories/resource/local_db/helpers/filter_requests_by_hashes_service.py) |
115134
| Hashing | [`hashed_request_decorator.py`](../stoobly_agent/app/proxy/mock/hashed_request_decorator.py) |

stoobly_agent/app/api/configs_controller.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def instance(cls):
3131
# GET /configs/policies
3232
def policies(self, context):
3333
settings = Settings.instance()
34-
active_mode = settings.proxy.intercept.active
34+
active_mode = settings.proxy.intercept.mode
3535

3636
if active_mode in [mode.MOCK, mode.TEST]:
3737
context.render(

stoobly_agent/app/cli/helpers/iterate_group_by.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def iterate_group_by(alias_name: str, trace_context: TraceContext, handler: Call
2828
_trace_alias.trace_request_id = trace_request.id
2929
_trace_alias.save()
3030

31-
trace_context = TraceContext(trace_context.endpoints_resource, trace)
31+
trace_context = TraceContext(trace_context.endpoint_cache, trace)
3232

3333
handler(trace_context)
3434

stoobly_agent/app/cli/helpers/openapi_endpoint_adapter.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import copy
2+
import hashlib
23
import itertools
34
import logging
45
import re
@@ -15,13 +16,41 @@
1516

1617
from stoobly_agent.lib.api.interfaces.endpoints import (
1718
Alias,
18-
EndpointShowResponse,
19+
OpenApiEndpointShowResponse,
1920
RequestComponentName,
2021
)
2122
from stoobly_agent.lib.utils.python_to_ruby_type import convert_reverse
2223

2324
from .schema_builder import SchemaBuilder
2425

26+
27+
def _md5_hex(value: str) -> str:
28+
return hashlib.md5(value.encode('utf-8')).hexdigest()
29+
30+
31+
def _endpoint_service_id(parsed_url, port_str: str) -> str:
32+
hostname = parsed_url.hostname or ""
33+
if hostname:
34+
hostname = hostname.lower()
35+
return _md5_hex(f"{hostname}:{port_str}")
36+
37+
38+
def compute_openapi_service_id(hostname: str, port: str) -> str:
39+
"""MD5 hex of ``{hostname}:{port}`` (hostname lowercased). Used by tests and callers."""
40+
hn = (hostname or "").lower()
41+
return _md5_hex(f"{hn}:{port}")
42+
43+
44+
def compute_openapi_endpoint_id(service_id: str, match_pattern: str, method: str) -> str:
45+
"""
46+
MD5 hex of ``service_id``, ``match_pattern``, and HTTP ``method`` (concatenated).
47+
48+
``method`` is included so operations that share the same ``match_pattern`` (e.g. GET vs POST)
49+
do not receive the same ``id``.
50+
"""
51+
return _md5_hex(f"{service_id}{match_pattern or ''}{method}")
52+
53+
2554
class OpenApiEndpointAdapter():
2655
def __init__(self, strict_refs=False):
2756
"""
@@ -34,7 +63,7 @@ def __init__(self, strict_refs=False):
3463
self.spec = None
3564
self.strict_refs = strict_refs
3665

37-
def adapt_from_file(self, file_path) -> List[EndpointShowResponse]:
66+
def adapt_from_file(self, file_path) -> List[OpenApiEndpointShowResponse]:
3867
spec = {}
3968

4069
with open(file_path, "r") as stream:
@@ -52,10 +81,9 @@ def adapt_from_file(self, file_path) -> List[EndpointShowResponse]:
5281

5382
return self.adapt(spec)
5483

55-
def adapt(self, spec: SchemaPath) -> List[EndpointShowResponse]:
84+
def adapt(self, spec: SchemaPath) -> List[OpenApiEndpointShowResponse]:
5685
self.spec = spec # Store spec for use in __dereference
5786
endpoints = []
58-
endpoint_counter = 0
5987
components = spec.get("components", {})
6088
schemas = components.get("schemas", {})
6189
paths = spec.getkey('paths')
@@ -81,11 +109,8 @@ def adapt(self, spec: SchemaPath) -> List[EndpointShowResponse]:
81109
if http_method not in path:
82110
continue
83111

84-
endpoint_counter += 1
85-
86112
parsed_url = urlparse(url)
87-
endpoint: EndpointShowResponse = {}
88-
endpoint['id'] = endpoint_counter
113+
endpoint: OpenApiEndpointShowResponse = {}
89114
endpoint['method'] = http_method.upper()
90115
endpoint['host'] = '-' if parsed_url.netloc == '' else parsed_url.netloc
91116

@@ -124,6 +149,10 @@ def adapt(self, spec: SchemaPath) -> List[EndpointShowResponse]:
124149
else:
125150
endpoint['port'] = str(parsed_url.port)
126151

152+
endpoint['service_id'] = _endpoint_service_id(parsed_url, endpoint['port'])
153+
mp = endpoint.get('match_pattern') or ''
154+
endpoint['id'] = _md5_hex(endpoint['service_id'] + mp + endpoint['method'])
155+
127156
alias_counter = 0
128157
header_param_counter = 0
129158
operation = path[http_method]
@@ -465,7 +494,7 @@ def __dereference(self, components: SchemaPath, reference: str, spec: SchemaPath
465494

466495
return current
467496

468-
def __convert_literal_component_param(self, endpoint: EndpointShowResponse,
497+
def __convert_literal_component_param(self, endpoint: OpenApiEndpointShowResponse,
469498
required_component_params: List[str], literal_component_params: Union[dict, list],
470499
component_name: str, literal_component_name: str) -> None:
471500

@@ -603,7 +632,7 @@ def __evaluate_servers(self, servers: SchemaPath) -> List[dict]:
603632

604633
return result
605634

606-
def __parse_responses(self, endpoint: EndpointShowResponse, responses: SchemaPath, components: SchemaPath):
635+
def __parse_responses(self, endpoint: OpenApiEndpointShowResponse, responses: SchemaPath, components: SchemaPath):
607636
for response_code, response_definition in responses.items():
608637
# Only support status code 200 for now
609638
if response_code != '200':

stoobly_agent/app/cli/helpers/replay_facade.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class ReplayCliOptions(TypedDict):
1515
group_by: str
1616
host: str
1717
lifecycle_hooks_path: str
18+
openapi_specification_path: str
1819
on_response: Callable
1920
project_key: str
2021
public_directory_path: str # Comma-separated list of paths, optionally with origin prefix
@@ -58,6 +59,7 @@ def common_cli_options(self, cli_options: ReplayCliOptions) -> ReplayCliOptions:
5859
'group_by': cli_options.get('group_by'),
5960
'host': cli_options.get('host'),
6061
'lifecycle_hooks_path': cli_options.get('lifecycle_hooks_path'),
62+
'openapi_specification_path': cli_options.get('openapi_specification_path'),
6163
'overwrite': cli_options.get('overwrite'),
6264
'public_dir_path': cli_options.get('public_dir_path'),
6365
'request_origin': request_origin.CLI,

stoobly_agent/app/cli/helpers/synchronize_request_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class SynchronizeRequestService():
4242
local_db_request_adapter: LocalDBRequestAdapter
4343

4444
def synchronize_request(self, request: Request, endpoint: EndpointShowResponse, lifecycle_hooks = {}):
45-
facade = EndpointFacade(None, -1).with_show_response(endpoint)
45+
facade = EndpointFacade(-1).with_show_response(endpoint)
4646
mitmproxy_request = OrmRequestAdapterFactory(request).mitmproxy_request()
4747

4848
# Query Params

stoobly_agent/app/cli/helpers/trace_context_facade.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,16 @@
33
from typing import List
44

55
from stoobly_agent.app.cli.helpers.trace_aliases import parse_aliases
6+
from stoobly_agent.app.proxy.mock.endpoint_cache import endpoint_cache
67
from stoobly_agent.app.proxy.replay.trace_context import TraceContext
78
from stoobly_agent.app.settings import Settings
8-
from stoobly_agent.lib.api.endpoints_resource import EndpointsResource
99
from stoobly_agent.lib.orm.trace import Trace
1010

1111
class TraceContextFacade():
1212

1313
def __init__(self, settings: Settings, trace: Trace = None):
1414
self.__settings = settings
15-
self.__endpoints_resource = EndpointsResource(self.__settings.remote.api_url, self.__settings.remote.api_key)
16-
self.__trace_context = TraceContext(self.__endpoints_resource, trace)
15+
self.__trace_context = TraceContext(endpoint_cache, trace)
1716

1817
@property
1918
def trace_context(self):

stoobly_agent/app/cli/request_cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ def snapshot_list(**kwargs):
180180
@click.option('--group-by', help='Repeat for each alias name.')
181181
@click.option('--host', help='Rewrite request host.')
182182
@click.option('--lifecycle-hooks-path', help='Path to lifecycle hooks script.')
183+
@click.option('--openapi-specification-path', help='Path to OpenAPI specification file.')
183184
@click.option(
184185
'--log-level', default=logger.WARNING, type=click.Choice(log_levels),
185186
help=f'''

stoobly_agent/app/cli/scenario_cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ def snapshot_list(**kwargs):
207207
Configure which logs to print. Defaults to {logger.WARNING}.
208208
'''
209209
)
210+
@click.option('--openapi-specification-path', help='Path to OpenAPI specification file.')
210211
@click.option(
211212
'--output-level', default=test_output_level.PASSED, type=click.Choice([test_output_level.FAILED, test_output_level.SKIPPED, test_output_level.PASSED]),
212213
help=f'''

stoobly_agent/app/models/factories/resource/local_db/request_adapter.py

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,16 @@ def show(self, request_id: str, **options: RequestShowParams) -> Tuple[RequestSh
7676
def response(self, **query_params: RequestColumns) -> 'Response':
7777
self.__adapt_scenario_id(query_params)
7878

79+
endpoint = None
7980
request = None
8081
retry = bool(query_params.get('retry'))
8182
should_compute = bool(query_params.get(request_query_params.COMPUTE))
83+
endpoint_promise = query_params.get(request_query_params.ENDPOINT_PROMISE)
84+
85+
if endpoint_promise:
86+
endpoint = endpoint_promise()
87+
88+
ignored_components = endpoint.get('ignored_components') if endpoint else []
8289

8390
if not query_params.get('request_id'):
8491
request_columns = { 'is_deleted': False, **query_params }
@@ -94,8 +101,7 @@ def response(self, **query_params: RequestColumns) -> 'Response':
94101
# Find most recent matching record
95102
requests = self.__request_orm.where_for(**request_columns).get()
96103

97-
if should_compute:
98-
ignored_components = self.__ignored_components(query_params.get(request_query_params.ENDPOINT_PROMISE))
104+
if should_compute and ignored_components:
99105
requests = filter_requests_by_hashes(requests, _component_hashes, ignored_components)
100106

101107
if len(requests) > 1 and 'scenario_id' in query_params:
@@ -118,18 +124,20 @@ def response(self, **query_params: RequestColumns) -> 'Response':
118124
request = None
119125

120126
if not request:
121-
endpoint_promise = None
122127
# Only attempt to provide ignored components when not retrying
123128
# and there are component hashes to re-compute. Otherwise matching by hostname and path already
124-
if not retry and component_hashes(query_params):
125-
endpoint_promise = query_params.get(request_query_params.ENDPOINT_PROMISE)
126-
return self.__handle_request_not_found(endpoint_promise)
129+
if endpoint and not retry and component_hashes(query_params):
130+
if ignored_components:
131+
return IgnoreComponentsResponseBuilder().build(ignored_components)
132+
return CustomNotFoundResponseBuilder().build()
127133

128134
response_record = request.response
129135
if not response_record:
130136
return CustomNotFoundResponseBuilder().build()
131137

132138
headers = {}
139+
if endpoint:
140+
headers[custom_headers.MOCK_REQUEST_ENDPOINT_ID] = str(endpoint.get('id'))
133141
headers[custom_headers.MOCK_REQUEST_ID] = str(request.id)
134142
headers[custom_headers.MOCK_REQUEST_KEY] = request.key()
135143
headers[custom_headers.RESPONSE_LATENCY] = str(request.latency)
@@ -140,15 +148,6 @@ def response(self, **query_params: RequestColumns) -> 'Response':
140148
.transform()
141149
)
142150

143-
def __handle_request_not_found(self, endpoint_promise):
144-
if endpoint_promise:
145-
ignored_components = self.__ignored_components(endpoint_promise)
146-
147-
if ignored_components:
148-
return IgnoreComponentsResponseBuilder().build(ignored_components)
149-
150-
return CustomNotFoundResponseBuilder().build()
151-
152151
def index(self, **query_params: RequestsIndexQueryParams) -> Tuple[RequestsIndexResponse, int]:
153152
self.__adapt_scenario_id(query_params)
154153

@@ -389,17 +388,4 @@ def __adapt_scenario_id(self, params: dict):
389388

390389
scenario = self.__scenario_orm.find_by(uuid=scenario_id)
391390
if scenario:
392-
params['scenario_id'] = scenario.id
393-
394-
def __ignored_components(self, endpoint_promise):
395-
if not endpoint_promise:
396-
return []
397-
398-
endpoint = endpoint_promise()
399-
400-
if endpoint:
401-
ignored_components = endpoint.get('ignored_components')
402-
403-
if ignored_components:
404-
return ignored_components
405-
return []
391+
params['scenario_id'] = scenario.id

0 commit comments

Comments
 (0)