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,622 @@
1
+ """
2
+ Mock API Response Management System
3
+
4
+ This module provides comprehensive functionality for creating and managing mock API responses
5
+ with support for pytest integration, automated testing, and efficient response management.
6
+ """
7
+
8
+ import json
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional, Union, Callable
12
+ from enum import Enum
13
+ import pytest
14
+ from pathlib import Path
15
+ import yaml
16
+
17
+
18
+ class ResponseType(Enum):
19
+ """Types of mock responses"""
20
+ STATIC = "static"
21
+ DYNAMIC = "dynamic"
22
+ TEMPLATED = "templated"
23
+ CONDITIONAL = "conditional"
24
+ DELAYED = "delayed"
25
+ ERROR = "error"
26
+
27
+
28
+ class HTTPMethod(Enum):
29
+ """HTTP methods supported by mock responses"""
30
+ GET = "GET"
31
+ POST = "POST"
32
+ PUT = "PUT"
33
+ DELETE = "DELETE"
34
+ PATCH = "PATCH"
35
+ HEAD = "HEAD"
36
+ OPTIONS = "OPTIONS"
37
+
38
+
39
+ @dataclass
40
+ class MockAPIResponse:
41
+ """
42
+ Core class for creating and managing mock API responses.
43
+
44
+ This class provides comprehensive functionality for defining mock responses
45
+ with support for static data, dynamic generation, templating, and conditional logic.
46
+ """
47
+
48
+ # Basic response properties
49
+ path: str
50
+ method: HTTPMethod = HTTPMethod.GET
51
+ status_code: int = 200
52
+ headers: Dict[str, str] = field(default_factory=dict)
53
+ body: Any = None
54
+
55
+ # Response type and behavior
56
+ response_type: ResponseType = ResponseType.STATIC
57
+ delay_ms: int = 0
58
+ error_probability: float = 0.0
59
+
60
+ # Conditional logic
61
+ conditions: List[Dict[str, Any]] = field(default_factory=list)
62
+ priority: int = 0
63
+
64
+ # Dynamic response properties
65
+ template_vars: Dict[str, Any] = field(default_factory=dict)
66
+ generator_func: Optional[Callable] = None
67
+ cache_ttl: int = 300 # 5 minutes default
68
+
69
+ # Metadata
70
+ name: Optional[str] = None
71
+ description: Optional[str] = None
72
+ tags: List[str] = field(default_factory=list)
73
+ created_at: float = field(default_factory=time.time)
74
+ updated_at: float = field(default_factory=time.time)
75
+
76
+ def __post_init__(self):
77
+ """Initialize response after creation"""
78
+ if self.name is None:
79
+ # Clean up path for name generation - remove leading slash and replace with single underscore
80
+ clean_path = self.path.lstrip('/').replace('/', '_').replace('{', '').replace('}', '')
81
+ self.name = f"{self.method.value}_{clean_path}"
82
+
83
+ # Set default headers if not provided
84
+ if not self.headers:
85
+ self.headers = {
86
+ "Content-Type": "application/json",
87
+ "X-Mock-Response": "true"
88
+ }
89
+
90
+ def matches_request(self, request_path: str, request_method: str,
91
+ request_headers: Dict[str, str] = None,
92
+ **kwargs) -> bool:
93
+ """
94
+ Check if this response matches the given request.
95
+
96
+ Args:
97
+ request_path: The request path
98
+ request_method: The HTTP method
99
+ request_headers: Request headers
100
+ request_body: Request body
101
+
102
+ Returns:
103
+ bool: True if response matches request
104
+ """
105
+ # Basic path and method matching
106
+ if not self._path_matches(request_path) or self.method.value != request_method:
107
+ return False
108
+
109
+ # Check conditions if any
110
+ if self.conditions:
111
+ request_body = kwargs.get('body')
112
+ return self._check_conditions(request_headers, request_body)
113
+
114
+ return True
115
+
116
+ def _path_matches(self, request_path: str) -> bool:
117
+ """Check if the request path matches this response's path"""
118
+ # Exact match
119
+ if self.path == request_path:
120
+ return True
121
+
122
+ # Pattern matching with wildcards
123
+ if '*' in self.path:
124
+ return self._wildcard_match(request_path)
125
+
126
+ # Parameter matching (e.g., /users/{id})
127
+ if '{' in self.path:
128
+ return self._parameter_match(request_path)
129
+
130
+ return False
131
+
132
+ def _wildcard_match(self, request_path: str) -> bool:
133
+ """Match paths with wildcards"""
134
+ import re
135
+ pattern = self.path.replace('*', '.*')
136
+ return bool(re.match(pattern, request_path))
137
+
138
+ def _parameter_match(self, request_path: str) -> bool:
139
+ """Match paths with parameters"""
140
+ import re
141
+ # Convert /users/{id} to regex pattern
142
+ pattern = re.sub(r'\{[^}]+\}', r'[^/]+', self.path)
143
+ # Add end anchor to prevent partial matches
144
+ pattern = pattern + '$'
145
+ return bool(re.match(pattern, request_path))
146
+
147
+ def _check_conditions(self, headers: Dict[str, str] = None,
148
+ body: Any = None) -> bool:
149
+ """Check if request meets all conditions"""
150
+ if not headers:
151
+ headers = {}
152
+
153
+ for condition in self.conditions:
154
+ if not self._evaluate_condition(condition, headers, body):
155
+ return False
156
+
157
+ return True
158
+
159
+ def _evaluate_condition(self, condition: Dict[str, Any],
160
+ headers: Dict[str, str], body: Any) -> bool:
161
+ """Evaluate a single condition"""
162
+ condition_type = condition.get('type', 'header')
163
+
164
+ if condition_type == 'header':
165
+ header_name = condition.get('name')
166
+ expected_value = condition.get('value')
167
+ return headers.get(header_name) == expected_value
168
+
169
+ elif condition_type == 'body':
170
+ field_path = condition.get('field')
171
+ expected_value = condition.get('value')
172
+ actual_value = self._get_nested_value(body, field_path)
173
+ return actual_value == expected_value
174
+
175
+ elif condition_type == 'custom':
176
+ func = condition.get('function')
177
+ return func(headers, body) if callable(func) else False
178
+
179
+ return False
180
+
181
+ def _get_nested_value(self, obj: Any, path: str) -> Any:
182
+ """Get nested value from object using dot notation"""
183
+ if not path:
184
+ return obj
185
+
186
+ keys = path.split('.')
187
+ current = obj
188
+
189
+ for key in keys:
190
+ if isinstance(current, dict):
191
+ current = current.get(key)
192
+ elif isinstance(current, list) and key.isdigit():
193
+ current = current[int(key)]
194
+ else:
195
+ return None
196
+
197
+ return current
198
+
199
+ def generate_response(self, request_context: Dict[str, Any] = None) -> Dict[str, Any]:
200
+ """
201
+ Generate the actual response based on type and context.
202
+
203
+ Args:
204
+ request_context: Additional context for response generation
205
+
206
+ Returns:
207
+ Dict containing status_code, headers, and body
208
+ """
209
+ # Check for errors
210
+ if self.error_probability > 0 and self._should_return_error():
211
+ return self._generate_error_response()
212
+
213
+ # Apply delay if specified
214
+ if self.delay_ms > 0:
215
+ time.sleep(self.delay_ms / 1000.0)
216
+
217
+ # Generate response based on type
218
+ if self.response_type == ResponseType.STATIC:
219
+ body = self.body
220
+ elif self.response_type == ResponseType.DYNAMIC:
221
+ body = self._generate_dynamic_response(request_context)
222
+ elif self.response_type == ResponseType.TEMPLATED:
223
+ body = self._generate_templated_response(request_context)
224
+ else:
225
+ body = self.body
226
+
227
+ return {
228
+ 'status_code': self.status_code,
229
+ 'headers': self.headers.copy(),
230
+ 'body': body
231
+ }
232
+
233
+ def _should_return_error(self) -> bool:
234
+ """Determine if an error should be returned based on probability"""
235
+ import random
236
+ return random.random() < self.error_probability
237
+
238
+ def _generate_error_response(self) -> Dict[str, Any]:
239
+ """Generate an error response"""
240
+ return {
241
+ 'status_code': 500,
242
+ 'headers': {
243
+ 'Content-Type': 'application/json',
244
+ 'X-Mock-Error': 'true'
245
+ },
246
+ 'body': {
247
+ 'error': 'Internal Server Error',
248
+ 'message': 'Mock error response',
249
+ 'timestamp': time.time()
250
+ }
251
+ }
252
+
253
+ def _generate_dynamic_response(self, context: Dict[str, Any] = None) -> Any:
254
+ """Generate dynamic response using generator function"""
255
+ if self.generator_func:
256
+ return self.generator_func(context or {})
257
+ return self.body
258
+
259
+ def _generate_templated_response(self, context: Dict[str, Any] = None) -> Any:
260
+ """Generate templated response with variable substitution"""
261
+ if isinstance(self.body, dict):
262
+ # Handle dictionary body with template variables
263
+ result = {}
264
+ vars_dict = {**self.template_vars, **(context or {})}
265
+
266
+ for key, value in self.body.items():
267
+ if isinstance(value, str):
268
+ # Replace template variables in string values
269
+ for var_key, var_value in vars_dict.items():
270
+ value = value.replace(f'{{{{{var_key}}}}}', str(var_value))
271
+ result[key] = value
272
+ return result
273
+ elif isinstance(self.body, str):
274
+ template = self.body
275
+ vars_dict = {**self.template_vars, **(context or {})}
276
+
277
+ for key, value in vars_dict.items():
278
+ template = template.replace(f'{{{{{key}}}}}', str(value))
279
+
280
+ try:
281
+ return json.loads(template)
282
+ except json.JSONDecodeError:
283
+ return template
284
+
285
+ return self.body
286
+
287
+ def to_dict(self) -> Dict[str, Any]:
288
+ """Convert response to dictionary for serialization"""
289
+ return {
290
+ 'name': self.name,
291
+ 'path': self.path,
292
+ 'method': self.method.value,
293
+ 'status_code': self.status_code,
294
+ 'headers': self.headers,
295
+ 'body': self.body,
296
+ 'response_type': self.response_type.value,
297
+ 'delay_ms': self.delay_ms,
298
+ 'error_probability': self.error_probability,
299
+ 'conditions': self.conditions,
300
+ 'priority': self.priority,
301
+ 'template_vars': self.template_vars,
302
+ 'description': self.description,
303
+ 'tags': self.tags,
304
+ 'created_at': self.created_at,
305
+ 'updated_at': self.updated_at
306
+ }
307
+
308
+ @classmethod
309
+ def from_dict(cls, data: Dict[str, Any]) -> 'MockAPIResponse':
310
+ """Create response from dictionary"""
311
+ data = data.copy()
312
+ data['method'] = HTTPMethod(data['method'])
313
+ data['response_type'] = ResponseType(data['response_type'])
314
+ return cls(**data)
315
+
316
+ def update(self, **kwargs) -> None:
317
+ """Update response properties"""
318
+ for key, value in kwargs.items():
319
+ if hasattr(self, key):
320
+ setattr(self, key, value)
321
+ self.updated_at = time.time()
322
+
323
+
324
+ @dataclass
325
+ class MockSet:
326
+ """
327
+ Efficient collection for managing multiple mock responses.
328
+
329
+ Provides fast lookup, filtering, and management capabilities for large
330
+ collections of mock responses.
331
+ """
332
+
333
+ name: str
334
+ responses: List[MockAPIResponse] = field(default_factory=list)
335
+ metadata: Dict[str, Any] = field(default_factory=dict)
336
+
337
+ def __post_init__(self):
338
+ """Initialize the mock set"""
339
+ self._build_index()
340
+
341
+ def _build_index(self) -> None:
342
+ """Build internal indexes for fast lookup"""
343
+ self._path_index = {}
344
+ self._method_index = {}
345
+ self._tag_index = {}
346
+ self._name_index = {}
347
+
348
+ for response in self.responses:
349
+ # Index by path
350
+ if response.path not in self._path_index:
351
+ self._path_index[response.path] = []
352
+ self._path_index[response.path].append(response)
353
+
354
+ # Index by method
355
+ method_key = response.method.value
356
+ if method_key not in self._method_index:
357
+ self._method_index[method_key] = []
358
+ self._method_index[method_key].append(response)
359
+
360
+ # Index by tags
361
+ for tag in response.tags:
362
+ if tag not in self._tag_index:
363
+ self._tag_index[tag] = []
364
+ self._tag_index[tag].append(response)
365
+
366
+ # Index by name
367
+ self._name_index[response.name] = response
368
+
369
+ def add_response(self, response: MockAPIResponse) -> None:
370
+ """Add a response to the set"""
371
+ self.responses.append(response)
372
+ self._build_index()
373
+
374
+ def remove_response(self, response_name: str) -> bool:
375
+ """Remove a response by name"""
376
+ if response_name in self._name_index:
377
+ response = self._name_index[response_name]
378
+ self.responses.remove(response)
379
+ self._build_index()
380
+ return True
381
+ return False
382
+
383
+ def find_matching_response(self, path: str, method: str,
384
+ headers: Dict[str, str] = None,
385
+ body: Any = None) -> Optional[MockAPIResponse]:
386
+ """
387
+ Find the best matching response for a request.
388
+
389
+ Returns the highest priority response that matches the request.
390
+ """
391
+ matching_responses = []
392
+
393
+ # Find all responses that match the request
394
+ for response in self.responses:
395
+ if response.matches_request(path, method, headers, body):
396
+ matching_responses.append(response)
397
+
398
+ if not matching_responses:
399
+ return None
400
+
401
+ # Return the highest priority response
402
+ return max(matching_responses, key=lambda r: r.priority)
403
+
404
+ def get_by_path(self, path: str) -> List[MockAPIResponse]:
405
+ """Get all responses for a specific path"""
406
+ return self._path_index.get(path, [])
407
+
408
+ def get_by_method(self, method: str) -> List[MockAPIResponse]:
409
+ """Get all responses for a specific HTTP method"""
410
+ return self._method_index.get(method, [])
411
+
412
+ def get_by_tag(self, tag: str) -> List[MockAPIResponse]:
413
+ """Get all responses with a specific tag"""
414
+ return self._tag_index.get(tag, [])
415
+
416
+ def get_by_name(self, name: str) -> Optional[MockAPIResponse]:
417
+ """Get a response by name"""
418
+ return self._name_index.get(name)
419
+
420
+ def filter(self, **kwargs) -> List[MockAPIResponse]:
421
+ """Filter responses by multiple criteria"""
422
+ filtered = self.responses
423
+
424
+ for key, value in kwargs.items():
425
+ if key == 'status_code':
426
+ filtered = [r for r in filtered if r.status_code == value]
427
+ elif key == 'response_type':
428
+ filtered = [r for r in filtered if r.response_type == value]
429
+ elif key == 'tags':
430
+ if isinstance(value, str):
431
+ filtered = [r for r in filtered if value in r.tags]
432
+ else:
433
+ filtered = [r for r in filtered if any(tag in r.tags for tag in value)]
434
+
435
+ return filtered
436
+
437
+ def to_dict(self) -> Dict[str, Any]:
438
+ """Convert mock set to dictionary"""
439
+ return {
440
+ 'name': self.name,
441
+ 'responses': [r.to_dict() for r in self.responses],
442
+ 'metadata': self.metadata
443
+ }
444
+
445
+ @classmethod
446
+ def from_dict(cls, data: Dict[str, Any]) -> 'MockSet':
447
+ """Create mock set from dictionary"""
448
+ responses = [MockAPIResponse.from_dict(r) for r in data['responses']]
449
+ return cls(
450
+ name=data['name'],
451
+ responses=responses,
452
+ metadata=data.get('metadata', {})
453
+ )
454
+
455
+ def save_to_file(self, filepath: str) -> None:
456
+ """Save mock set to file"""
457
+ with open(filepath, 'w') as f:
458
+ yaml.dump(self.to_dict(), f, default_flow_style=False)
459
+
460
+ @classmethod
461
+ def load_from_file(cls, filepath: str) -> 'MockSet':
462
+ """Load mock set from file"""
463
+ with open(filepath, 'r') as f:
464
+ data = yaml.safe_load(f)
465
+ return cls.from_dict(data)
466
+
467
+
468
+ # Example subclasses for common API interactions
469
+ class CommitResponse(MockAPIResponse):
470
+ """Mock response for Git commit operations"""
471
+
472
+ def __init__(self, **kwargs):
473
+ super().__init__(
474
+ path="/repos/{owner}/{repo}/git/commits",
475
+ method=HTTPMethod.POST,
476
+ status_code=201,
477
+ response_type=ResponseType.TEMPLATED,
478
+ template_vars={
479
+ 'sha': 'abc123def456',
480
+ 'message': 'feat: add new feature',
481
+ 'author': 'John Doe'
482
+ },
483
+ body={
484
+ 'sha': '{{sha}}',
485
+ 'message': '{{message}}',
486
+ 'author': {
487
+ 'name': '{{author}}',
488
+ 'email': 'john@example.com'
489
+ },
490
+ 'committer': {
491
+ 'name': '{{author}}',
492
+ 'email': 'john@example.com'
493
+ }
494
+ },
495
+ **kwargs
496
+ )
497
+
498
+
499
+ class ForkResponse(MockAPIResponse):
500
+ """Mock response for repository fork operations"""
501
+
502
+ def __init__(self, **kwargs):
503
+ super().__init__(
504
+ path="/repos/{owner}/{repo}/forks",
505
+ method=HTTPMethod.POST,
506
+ status_code=202,
507
+ response_type=ResponseType.STATIC,
508
+ body={
509
+ 'id': 12345,
510
+ 'name': 'forked-repo',
511
+ 'full_name': 'new-owner/forked-repo',
512
+ 'fork': True,
513
+ 'source': {
514
+ 'id': 67890,
515
+ 'name': 'original-repo',
516
+ 'full_name': 'original-owner/original-repo'
517
+ }
518
+ },
519
+ **kwargs
520
+ )
521
+
522
+
523
+ class PushResponse(MockAPIResponse):
524
+ """Mock response for Git push operations"""
525
+
526
+ def __init__(self, **kwargs):
527
+ super().__init__(
528
+ path="/repos/{owner}/{repo}/git/refs/heads/{branch}",
529
+ method=HTTPMethod.PATCH,
530
+ status_code=200,
531
+ response_type=ResponseType.TEMPLATED,
532
+ template_vars={
533
+ 'ref': 'refs/heads/main',
534
+ 'sha': 'def456ghi789'
535
+ },
536
+ body={
537
+ 'ref': '{{ref}}',
538
+ 'sha': '{{sha}}',
539
+ 'url': 'https://api.github.com/repos/owner/repo/git/refs/heads/main'
540
+ },
541
+ **kwargs
542
+ )
543
+
544
+
545
+ class ForcePushResponse(MockAPIResponse):
546
+ """Mock response for force push operations"""
547
+
548
+ def __init__(self, **kwargs):
549
+ super().__init__(
550
+ path="/repos/{owner}/{repo}/git/refs/heads/{branch}",
551
+ method=HTTPMethod.PATCH,
552
+ status_code=200,
553
+ response_type=ResponseType.STATIC,
554
+ body={
555
+ 'ref': 'refs/heads/main',
556
+ 'sha': 'force123push456',
557
+ 'force': True,
558
+ 'url': 'https://api.github.com/repos/owner/repo/git/refs/heads/main'
559
+ },
560
+ **kwargs
561
+ )
562
+
563
+
564
+ # Pytest fixture for easy integration
565
+ @pytest.fixture
566
+ def setup_api_mocks():
567
+ """
568
+ Pytest fixture for setting up mock API responses in tests.
569
+
570
+ Usage:
571
+ def test_api_call(setup_api_mocks):
572
+ mock_set = setup_api_mocks
573
+ mock_set.add_response(CommitResponse())
574
+ # Your test code here
575
+ """
576
+ mock_set = MockSet("test_mocks")
577
+ return mock_set
578
+
579
+
580
+ # Convenience functions for common operations
581
+ def create_user_response(user_id: str = "123", name: str = "John Doe") -> MockAPIResponse:
582
+ """Create a mock user response"""
583
+ return MockAPIResponse(
584
+ path=f"/users/{user_id}",
585
+ method=HTTPMethod.GET,
586
+ status_code=200,
587
+ response_type=ResponseType.TEMPLATED,
588
+ template_vars={'user_id': user_id, 'name': name},
589
+ body={
590
+ 'id': '{{user_id}}',
591
+ 'name': '{{name}}',
592
+ 'email': 'john@example.com',
593
+ 'created_at': '2023-01-01T00:00:00Z'
594
+ }
595
+ )
596
+
597
+
598
+ def create_error_response(status_code: int = 404, message: str = "Not found") -> MockAPIResponse:
599
+ """Create a mock error response"""
600
+ return MockAPIResponse(
601
+ path="*",
602
+ method=HTTPMethod.GET,
603
+ status_code=status_code,
604
+ response_type=ResponseType.STATIC,
605
+ body={
606
+ 'error': True,
607
+ 'message': message,
608
+ 'status_code': status_code
609
+ }
610
+ )
611
+
612
+
613
+ def create_delayed_response(delay_ms: int = 1000) -> MockAPIResponse:
614
+ """Create a mock response with delay"""
615
+ return MockAPIResponse(
616
+ path="/slow-endpoint",
617
+ method=HTTPMethod.GET,
618
+ status_code=200,
619
+ response_type=ResponseType.STATIC,
620
+ delay_ms=delay_ms,
621
+ body={'message': 'Response delayed'}
622
+ )