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.
@@ -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()