api-mocker 0.2.0__py3-none-any.whl → 0.4.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.
- api_mocker/cli.py +532 -0
- api_mocker/enhanced_analytics.py +542 -0
- api_mocker/mock_responses.py +622 -0
- api_mocker/scenarios.py +338 -0
- api_mocker/smart_matching.py +415 -0
- {api_mocker-0.2.0.dist-info → api_mocker-0.4.0.dist-info}/METADATA +2 -2
- {api_mocker-0.2.0.dist-info → api_mocker-0.4.0.dist-info}/RECORD +11 -7
- {api_mocker-0.2.0.dist-info → api_mocker-0.4.0.dist-info}/WHEEL +0 -0
- {api_mocker-0.2.0.dist-info → api_mocker-0.4.0.dist-info}/entry_points.txt +0 -0
- {api_mocker-0.2.0.dist-info → api_mocker-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {api_mocker-0.2.0.dist-info → api_mocker-0.4.0.dist-info}/top_level.txt +0 -0
api_mocker/scenarios.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scenario-Based Mocking System
|
|
3
|
+
|
|
4
|
+
Provides advanced scenario management for API mocking including:
|
|
5
|
+
- Multiple scenarios (happy path, error states, edge cases)
|
|
6
|
+
- Scenario switching via headers or query params
|
|
7
|
+
- Conditional responses based on request data
|
|
8
|
+
- A/B testing support for different response patterns
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import random
|
|
13
|
+
from typing import Dict, Any, List, Optional, Callable, Union
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timedelta
|
|
16
|
+
import re
|
|
17
|
+
from enum import Enum
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ScenarioType(Enum):
|
|
21
|
+
HAPPY_PATH = "happy_path"
|
|
22
|
+
ERROR_SCENARIO = "error_scenario"
|
|
23
|
+
EDGE_CASE = "edge_case"
|
|
24
|
+
PERFORMANCE_TEST = "performance_test"
|
|
25
|
+
A_B_TEST = "a_b_test"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ScenarioCondition:
|
|
30
|
+
"""Defines conditions for when a scenario should be active."""
|
|
31
|
+
header_match: Optional[Dict[str, str]] = None
|
|
32
|
+
query_param_match: Optional[Dict[str, str]] = None
|
|
33
|
+
body_match: Optional[Dict[str, Any]] = None
|
|
34
|
+
path_match: Optional[str] = None
|
|
35
|
+
method_match: Optional[str] = None
|
|
36
|
+
probability: float = 1.0 # For A/B testing
|
|
37
|
+
time_window: Optional[Dict[str, str]] = None # Start/end times
|
|
38
|
+
|
|
39
|
+
def matches(self, headers: Dict, query_params: Dict, body: Any, path: str, method: str) -> bool:
|
|
40
|
+
"""Check if the condition matches the current request."""
|
|
41
|
+
# Check probability for A/B testing
|
|
42
|
+
if random.random() > self.probability:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
# Check time window
|
|
46
|
+
if self.time_window:
|
|
47
|
+
now = datetime.now()
|
|
48
|
+
start_time = datetime.fromisoformat(self.time_window.get('start', '00:00'))
|
|
49
|
+
end_time = datetime.fromisoformat(self.time_window.get('end', '23:59'))
|
|
50
|
+
current_time = now.replace(year=1900, month=1, day=1)
|
|
51
|
+
if not (start_time <= current_time <= end_time):
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
# Check headers
|
|
55
|
+
if self.header_match:
|
|
56
|
+
for key, value in self.header_match.items():
|
|
57
|
+
if headers.get(key) != value:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
# Check query parameters
|
|
61
|
+
if self.query_param_match:
|
|
62
|
+
for key, value in self.query_param_match.items():
|
|
63
|
+
if query_params.get(key) != value:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
# Check body
|
|
67
|
+
if self.body_match and body:
|
|
68
|
+
for key, value in self.body_match.items():
|
|
69
|
+
if isinstance(body, dict) and body.get(key) != value:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
# Check path
|
|
73
|
+
if self.path_match:
|
|
74
|
+
if not re.search(self.path_match, path):
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
# Check method
|
|
78
|
+
if self.method_match and method.upper() != self.method_match.upper():
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ScenarioResponse:
|
|
86
|
+
"""Defines a response for a specific scenario."""
|
|
87
|
+
status_code: int = 200
|
|
88
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
89
|
+
body: Union[Dict, List, str, Callable] = None
|
|
90
|
+
delay: float = 0
|
|
91
|
+
error_message: Optional[str] = None
|
|
92
|
+
dynamic: bool = False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Scenario:
|
|
97
|
+
"""Represents a complete scenario configuration."""
|
|
98
|
+
name: str
|
|
99
|
+
description: str
|
|
100
|
+
scenario_type: ScenarioType
|
|
101
|
+
condition: ScenarioCondition
|
|
102
|
+
responses: Dict[str, ScenarioResponse] # path -> response mapping
|
|
103
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
104
|
+
active: bool = True
|
|
105
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ScenarioManager:
|
|
109
|
+
"""Manages multiple scenarios and scenario switching."""
|
|
110
|
+
|
|
111
|
+
def __init__(self):
|
|
112
|
+
self.scenarios: Dict[str, Scenario] = {}
|
|
113
|
+
self.active_scenario: Optional[str] = None
|
|
114
|
+
self.default_scenario: str = "happy_path"
|
|
115
|
+
self.scenario_history: List[Dict] = []
|
|
116
|
+
|
|
117
|
+
def add_scenario(self, scenario: Scenario):
|
|
118
|
+
"""Add a new scenario to the manager."""
|
|
119
|
+
self.scenarios[scenario.name] = scenario
|
|
120
|
+
if scenario.name == "happy_path" and not self.default_scenario:
|
|
121
|
+
self.default_scenario = scenario.name
|
|
122
|
+
|
|
123
|
+
def get_scenario(self, name: str) -> Optional[Scenario]:
|
|
124
|
+
"""Get a scenario by name."""
|
|
125
|
+
return self.scenarios.get(name)
|
|
126
|
+
|
|
127
|
+
def list_scenarios(self) -> List[str]:
|
|
128
|
+
"""List all available scenarios."""
|
|
129
|
+
return list(self.scenarios.keys())
|
|
130
|
+
|
|
131
|
+
def activate_scenario(self, name: str) -> bool:
|
|
132
|
+
"""Activate a specific scenario."""
|
|
133
|
+
if name in self.scenarios:
|
|
134
|
+
self.active_scenario = name
|
|
135
|
+
self._log_scenario_activation(name)
|
|
136
|
+
return True
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def deactivate_scenario(self):
|
|
140
|
+
"""Deactivate the current scenario and return to default."""
|
|
141
|
+
if self.active_scenario:
|
|
142
|
+
self._log_scenario_deactivation(self.active_scenario)
|
|
143
|
+
self.active_scenario = None
|
|
144
|
+
|
|
145
|
+
def get_matching_scenario(self, headers: Dict, query_params: Dict, body: Any, path: str, method: str) -> Optional[Scenario]:
|
|
146
|
+
"""Find the best matching scenario for the current request."""
|
|
147
|
+
# First check if there's an active scenario
|
|
148
|
+
if self.active_scenario:
|
|
149
|
+
scenario = self.scenarios.get(self.active_scenario)
|
|
150
|
+
if scenario and scenario.active:
|
|
151
|
+
return scenario
|
|
152
|
+
|
|
153
|
+
# Then check all scenarios for matching conditions
|
|
154
|
+
matching_scenarios = []
|
|
155
|
+
for scenario in self.scenarios.values():
|
|
156
|
+
if scenario.active and scenario.condition.matches(headers, query_params, body, path, method):
|
|
157
|
+
matching_scenarios.append(scenario)
|
|
158
|
+
|
|
159
|
+
if matching_scenarios:
|
|
160
|
+
# For A/B testing, randomly select from matching scenarios
|
|
161
|
+
if any(s.scenario_type == ScenarioType.A_B_TEST for s in matching_scenarios):
|
|
162
|
+
return random.choice(matching_scenarios)
|
|
163
|
+
# Otherwise, return the first matching scenario
|
|
164
|
+
return matching_scenarios[0]
|
|
165
|
+
|
|
166
|
+
# Return default scenario
|
|
167
|
+
return self.scenarios.get(self.default_scenario)
|
|
168
|
+
|
|
169
|
+
def get_response_for_path(self, path: str, headers: Dict, query_params: Dict, body: Any, method: str) -> Optional[ScenarioResponse]:
|
|
170
|
+
"""Get the appropriate response for a path based on the matching scenario."""
|
|
171
|
+
scenario = self.get_matching_scenario(headers, query_params, body, path, method)
|
|
172
|
+
if scenario:
|
|
173
|
+
return scenario.responses.get(path)
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def create_happy_path_scenario(self) -> Scenario:
|
|
177
|
+
"""Create a default happy path scenario."""
|
|
178
|
+
return Scenario(
|
|
179
|
+
name="happy_path",
|
|
180
|
+
description="Default happy path scenario with successful responses",
|
|
181
|
+
scenario_type=ScenarioType.HAPPY_PATH,
|
|
182
|
+
condition=ScenarioCondition(),
|
|
183
|
+
responses={},
|
|
184
|
+
metadata={"auto_generated": True}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def create_error_scenario(self, error_type: str = "server_error") -> Scenario:
|
|
188
|
+
"""Create an error scenario."""
|
|
189
|
+
error_responses = {
|
|
190
|
+
"server_error": ScenarioResponse(
|
|
191
|
+
status_code=500,
|
|
192
|
+
body={"error": "Internal Server Error", "code": "INTERNAL_ERROR"},
|
|
193
|
+
delay=0.5
|
|
194
|
+
),
|
|
195
|
+
"not_found": ScenarioResponse(
|
|
196
|
+
status_code=404,
|
|
197
|
+
body={"error": "Resource Not Found", "code": "NOT_FOUND"}
|
|
198
|
+
),
|
|
199
|
+
"unauthorized": ScenarioResponse(
|
|
200
|
+
status_code=401,
|
|
201
|
+
body={"error": "Unauthorized", "code": "UNAUTHORIZED"}
|
|
202
|
+
),
|
|
203
|
+
"rate_limited": ScenarioResponse(
|
|
204
|
+
status_code=429,
|
|
205
|
+
body={"error": "Rate Limit Exceeded", "code": "RATE_LIMITED"},
|
|
206
|
+
headers={"Retry-After": "60"}
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return Scenario(
|
|
211
|
+
name=f"error_{error_type}",
|
|
212
|
+
description=f"Error scenario for {error_type}",
|
|
213
|
+
scenario_type=ScenarioType.ERROR_SCENARIO,
|
|
214
|
+
condition=ScenarioCondition(),
|
|
215
|
+
responses={path: error_responses[error_type] for path in ["*"]},
|
|
216
|
+
metadata={"error_type": error_type}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def create_performance_test_scenario(self, delay_range: tuple = (1, 5)) -> Scenario:
|
|
220
|
+
"""Create a performance testing scenario with delays."""
|
|
221
|
+
return Scenario(
|
|
222
|
+
name="performance_test",
|
|
223
|
+
description="Performance testing scenario with random delays",
|
|
224
|
+
scenario_type=ScenarioType.PERFORMANCE_TEST,
|
|
225
|
+
condition=ScenarioCondition(),
|
|
226
|
+
responses={
|
|
227
|
+
"*": ScenarioResponse(
|
|
228
|
+
status_code=200,
|
|
229
|
+
body={"message": "Performance test response"},
|
|
230
|
+
delay=random.uniform(*delay_range)
|
|
231
|
+
)
|
|
232
|
+
},
|
|
233
|
+
metadata={"delay_range": delay_range}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def create_a_b_test_scenario(self, variant_a_probability: float = 0.5) -> Scenario:
|
|
237
|
+
"""Create an A/B testing scenario."""
|
|
238
|
+
return Scenario(
|
|
239
|
+
name="a_b_test",
|
|
240
|
+
description="A/B testing scenario with two variants",
|
|
241
|
+
scenario_type=ScenarioType.A_B_TEST,
|
|
242
|
+
condition=ScenarioCondition(probability=1.0),
|
|
243
|
+
responses={
|
|
244
|
+
"variant_a": ScenarioResponse(
|
|
245
|
+
status_code=200,
|
|
246
|
+
body={"variant": "A", "message": "Variant A response"}
|
|
247
|
+
),
|
|
248
|
+
"variant_b": ScenarioResponse(
|
|
249
|
+
status_code=200,
|
|
250
|
+
body={"variant": "B", "message": "Variant B response"}
|
|
251
|
+
)
|
|
252
|
+
},
|
|
253
|
+
metadata={"variant_a_probability": variant_a_probability}
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def export_scenarios(self, format: str = "json") -> str:
|
|
257
|
+
"""Export scenarios to various formats."""
|
|
258
|
+
if format == "json":
|
|
259
|
+
scenarios_data = {}
|
|
260
|
+
for name, scenario in self.scenarios.items():
|
|
261
|
+
scenarios_data[name] = {
|
|
262
|
+
"name": scenario.name,
|
|
263
|
+
"description": scenario.description,
|
|
264
|
+
"scenario_type": scenario.scenario_type.value,
|
|
265
|
+
"active": scenario.active,
|
|
266
|
+
"metadata": scenario.metadata,
|
|
267
|
+
"responses": {
|
|
268
|
+
path: {
|
|
269
|
+
"status_code": resp.status_code,
|
|
270
|
+
"headers": resp.headers,
|
|
271
|
+
"body": resp.body,
|
|
272
|
+
"delay": resp.delay,
|
|
273
|
+
"error_message": resp.error_message,
|
|
274
|
+
"dynamic": resp.dynamic
|
|
275
|
+
}
|
|
276
|
+
for path, resp in scenario.responses.items()
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return json.dumps(scenarios_data, indent=2, default=str)
|
|
280
|
+
else:
|
|
281
|
+
raise ValueError(f"Unsupported export format: {format}")
|
|
282
|
+
|
|
283
|
+
def import_scenarios(self, data: str, format: str = "json"):
|
|
284
|
+
"""Import scenarios from various formats."""
|
|
285
|
+
if format == "json":
|
|
286
|
+
scenarios_data = json.loads(data)
|
|
287
|
+
for name, scenario_data in scenarios_data.items():
|
|
288
|
+
scenario = Scenario(
|
|
289
|
+
name=scenario_data["name"],
|
|
290
|
+
description=scenario_data["description"],
|
|
291
|
+
scenario_type=ScenarioType(scenario_data["scenario_type"]),
|
|
292
|
+
condition=ScenarioCondition(), # Default condition
|
|
293
|
+
responses={
|
|
294
|
+
path: ScenarioResponse(**resp_data)
|
|
295
|
+
for path, resp_data in scenario_data["responses"].items()
|
|
296
|
+
},
|
|
297
|
+
metadata=scenario_data.get("metadata", {}),
|
|
298
|
+
active=scenario_data.get("active", True)
|
|
299
|
+
)
|
|
300
|
+
self.add_scenario(scenario)
|
|
301
|
+
else:
|
|
302
|
+
raise ValueError(f"Unsupported import format: {format}")
|
|
303
|
+
|
|
304
|
+
def get_scenario_statistics(self) -> Dict[str, Any]:
|
|
305
|
+
"""Get statistics about scenario usage."""
|
|
306
|
+
stats = {
|
|
307
|
+
"total_scenarios": len(self.scenarios),
|
|
308
|
+
"active_scenarios": len([s for s in self.scenarios.values() if s.active]),
|
|
309
|
+
"scenario_types": {},
|
|
310
|
+
"current_active": self.active_scenario,
|
|
311
|
+
"usage_history": self.scenario_history[-100:] # Last 100 activations
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for scenario in self.scenarios.values():
|
|
315
|
+
scenario_type = scenario.scenario_type.value
|
|
316
|
+
stats["scenario_types"][scenario_type] = stats["scenario_types"].get(scenario_type, 0) + 1
|
|
317
|
+
|
|
318
|
+
return stats
|
|
319
|
+
|
|
320
|
+
def _log_scenario_activation(self, scenario_name: str):
|
|
321
|
+
"""Log scenario activation for analytics."""
|
|
322
|
+
self.scenario_history.append({
|
|
323
|
+
"action": "activated",
|
|
324
|
+
"scenario": scenario_name,
|
|
325
|
+
"timestamp": datetime.now().isoformat()
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
def _log_scenario_deactivation(self, scenario_name: str):
|
|
329
|
+
"""Log scenario deactivation for analytics."""
|
|
330
|
+
self.scenario_history.append({
|
|
331
|
+
"action": "deactivated",
|
|
332
|
+
"scenario": scenario_name,
|
|
333
|
+
"timestamp": datetime.now().isoformat()
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# Global scenario manager instance
|
|
338
|
+
scenario_manager = ScenarioManager()
|