api-mocker 0.4.0__py3-none-any.whl → 0.5.1__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/auth_system.py +643 -0
- api_mocker/cli.py +294 -1
- api_mocker/core.py +26 -6
- api_mocker/database_integration.py +588 -0
- api_mocker/graphql_mock.py +602 -0
- api_mocker/ml_integration.py +716 -0
- api_mocker/mock_responses.py +21 -28
- api_mocker/resources.py +176 -0
- api_mocker/server.py +77 -7
- api_mocker/websocket_mock.py +476 -0
- api_mocker-0.5.1.dist-info/METADATA +782 -0
- api_mocker-0.5.1.dist-info/RECORD +29 -0
- {api_mocker-0.4.0.dist-info → api_mocker-0.5.1.dist-info}/WHEEL +1 -1
- api_mocker-0.4.0.dist-info/METADATA +0 -464
- api_mocker-0.4.0.dist-info/RECORD +0 -23
- {api_mocker-0.4.0.dist-info → api_mocker-0.5.1.dist-info}/entry_points.txt +0 -0
- {api_mocker-0.4.0.dist-info → api_mocker-0.5.1.dist-info}/licenses/LICENSE +0 -0
- {api_mocker-0.4.0.dist-info → api_mocker-0.5.1.dist-info}/top_level.txt +0 -0
api_mocker/mock_responses.py
CHANGED
|
@@ -89,6 +89,7 @@ class MockAPIResponse:
|
|
|
89
89
|
|
|
90
90
|
def matches_request(self, request_path: str, request_method: str,
|
|
91
91
|
request_headers: Dict[str, str] = None,
|
|
92
|
+
body: Any = None,
|
|
92
93
|
**kwargs) -> bool:
|
|
93
94
|
"""
|
|
94
95
|
Check if this response matches the given request.
|
|
@@ -97,19 +98,21 @@ class MockAPIResponse:
|
|
|
97
98
|
request_path: The request path
|
|
98
99
|
request_method: The HTTP method
|
|
99
100
|
request_headers: Request headers
|
|
100
|
-
|
|
101
|
+
body: Request body
|
|
101
102
|
|
|
102
103
|
Returns:
|
|
103
104
|
bool: True if response matches request
|
|
104
105
|
"""
|
|
106
|
+
# Handle case where body is passed in kwargs (for backward compatibility)
|
|
107
|
+
if body is None and 'request_body' in kwargs:
|
|
108
|
+
body = kwargs['request_body']
|
|
105
109
|
# Basic path and method matching
|
|
106
110
|
if not self._path_matches(request_path) or self.method.value != request_method:
|
|
107
111
|
return False
|
|
108
112
|
|
|
109
113
|
# Check conditions if any
|
|
110
114
|
if self.conditions:
|
|
111
|
-
|
|
112
|
-
return self._check_conditions(request_headers, request_body)
|
|
115
|
+
return self._check_conditions(request_headers, body)
|
|
113
116
|
|
|
114
117
|
return True
|
|
115
118
|
|
|
@@ -258,31 +261,21 @@ class MockAPIResponse:
|
|
|
258
261
|
|
|
259
262
|
def _generate_templated_response(self, context: Dict[str, Any] = None) -> Any:
|
|
260
263
|
"""Generate templated response with variable substitution"""
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
264
|
+
vars_dict = {**self.template_vars, **(context or {})}
|
|
265
|
+
return self._recursive_replace(self.body, vars_dict)
|
|
266
|
+
|
|
267
|
+
def _recursive_replace(self, obj: Any, vars_dict: Dict[str, Any]) -> Any:
|
|
268
|
+
"""Recursively replace template variables in object"""
|
|
269
|
+
if isinstance(obj, dict):
|
|
270
|
+
return {k: self._recursive_replace(v, vars_dict) for k, v in obj.items()}
|
|
271
|
+
elif isinstance(obj, list):
|
|
272
|
+
return [self._recursive_replace(item, vars_dict) for item in obj]
|
|
273
|
+
elif isinstance(obj, str):
|
|
274
|
+
for var_key, var_value in vars_dict.items():
|
|
275
|
+
obj = obj.replace(f'{{{{{var_key}}}}}', str(var_value))
|
|
276
|
+
return obj
|
|
277
|
+
else:
|
|
278
|
+
return obj
|
|
286
279
|
|
|
287
280
|
def to_dict(self) -> Dict[str, Any]:
|
|
288
281
|
"""Convert response to dictionary for serialization"""
|
api_mocker/resources.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from typing import Dict, Any, List, Optional
|
|
2
|
+
import math
|
|
3
|
+
from .core import CoreEngine
|
|
4
|
+
|
|
5
|
+
class ResourceHandler:
|
|
6
|
+
"""
|
|
7
|
+
Generic handler for stateful REST resources.
|
|
8
|
+
Uses CoreEngine's StateManager for persistence.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, resource_name: str, id_field: str = "id"):
|
|
12
|
+
self.resource_name = resource_name
|
|
13
|
+
self.id_field = id_field
|
|
14
|
+
|
|
15
|
+
def _get_collection(self, engine: CoreEngine) -> List[Dict]:
|
|
16
|
+
"""Get the full collection from state manager."""
|
|
17
|
+
collection = engine.state_manager.get_data(self.resource_name)
|
|
18
|
+
if collection is None:
|
|
19
|
+
collection = []
|
|
20
|
+
engine.state_manager.set_data(self.resource_name, collection)
|
|
21
|
+
return collection
|
|
22
|
+
|
|
23
|
+
def _save_collection(self, engine: CoreEngine, collection: List[Dict]):
|
|
24
|
+
"""Save the collection to state manager."""
|
|
25
|
+
engine.state_manager.set_data(self.resource_name, collection)
|
|
26
|
+
|
|
27
|
+
def list(self, path: str, method: str, headers: Dict, body: Any, engine: CoreEngine, query_params: Dict = None) -> Dict:
|
|
28
|
+
"""
|
|
29
|
+
Handle GET /resource
|
|
30
|
+
Supports: search (q=), filtering (?field=value), pagination (page, limit), sorting (sort)
|
|
31
|
+
"""
|
|
32
|
+
collection = self._get_collection(engine)
|
|
33
|
+
query_params = query_params or {}
|
|
34
|
+
|
|
35
|
+
# 1. Filtering & Search
|
|
36
|
+
filtered = []
|
|
37
|
+
search_query = query_params.get('q')
|
|
38
|
+
|
|
39
|
+
for item in collection:
|
|
40
|
+
matches = True
|
|
41
|
+
|
|
42
|
+
# Simple Filter Matching
|
|
43
|
+
for key, value in query_params.items():
|
|
44
|
+
if key in ['page', 'limit', 'sort', 'q']:
|
|
45
|
+
continue
|
|
46
|
+
if str(item.get(key, '')) != value:
|
|
47
|
+
matches = False
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
# Search Query (naive text match across all string fields)
|
|
51
|
+
if matches and search_query:
|
|
52
|
+
text_match = False
|
|
53
|
+
for v in item.values():
|
|
54
|
+
if isinstance(v, str) and search_query.lower() in v.lower():
|
|
55
|
+
text_match = True
|
|
56
|
+
break
|
|
57
|
+
if not text_match:
|
|
58
|
+
matches = False
|
|
59
|
+
|
|
60
|
+
if matches:
|
|
61
|
+
filtered.append(item)
|
|
62
|
+
|
|
63
|
+
# 2. Sorting
|
|
64
|
+
sort_param = query_params.get('sort')
|
|
65
|
+
if sort_param:
|
|
66
|
+
reverse = False
|
|
67
|
+
if sort_param.endswith('_desc'):
|
|
68
|
+
key = sort_param[:-5]
|
|
69
|
+
reverse = True
|
|
70
|
+
elif sort_param.endswith('_asc'):
|
|
71
|
+
key = sort_param[:-4]
|
|
72
|
+
else:
|
|
73
|
+
key = sort_param
|
|
74
|
+
|
|
75
|
+
# Basic sort ability
|
|
76
|
+
filtered.sort(key=lambda x: str(x.get(key, '')), reverse=reverse)
|
|
77
|
+
|
|
78
|
+
# 3. Pagination
|
|
79
|
+
page = int(query_params.get('page', 1))
|
|
80
|
+
limit = int(query_params.get('limit', 10))
|
|
81
|
+
total = len(filtered)
|
|
82
|
+
total_pages = math.ceil(total / limit)
|
|
83
|
+
|
|
84
|
+
start = (page - 1) * limit
|
|
85
|
+
end = start + limit
|
|
86
|
+
paginated_items = filtered[start:end]
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
"status_code": 200,
|
|
90
|
+
"body": {
|
|
91
|
+
"data": paginated_items,
|
|
92
|
+
"meta": {
|
|
93
|
+
"total": total,
|
|
94
|
+
"page": page,
|
|
95
|
+
"limit": limit,
|
|
96
|
+
"total_pages": total_pages
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def create(self, path: str, method: str, headers: Dict, body: Any, engine: CoreEngine, query_params: Dict = None) -> Dict:
|
|
102
|
+
"""Handle POST /resource"""
|
|
103
|
+
collection = self._get_collection(engine)
|
|
104
|
+
|
|
105
|
+
new_item = body if isinstance(body, dict) else {}
|
|
106
|
+
|
|
107
|
+
# Auto-generate ID if missing
|
|
108
|
+
if self.id_field not in new_item:
|
|
109
|
+
new_id = engine.state_manager.get_next_id(self.resource_name)
|
|
110
|
+
new_item[self.id_field] = str(new_id) # Using string IDs for consistency
|
|
111
|
+
|
|
112
|
+
import time
|
|
113
|
+
new_item['created_at'] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
114
|
+
|
|
115
|
+
collection.append(new_item)
|
|
116
|
+
self._save_collection(engine, collection)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"status_code": 201,
|
|
120
|
+
"body": new_item
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def get(self, path: str, method: str, headers: Dict, body: Any, engine: CoreEngine, query_params: Dict = None) -> Dict:
|
|
124
|
+
"""Handle GET /resource/{id}"""
|
|
125
|
+
# We need to extract ID from the path.
|
|
126
|
+
# Since we don't have direct access to resolved path params here easily without hacky parsing of 'path',
|
|
127
|
+
# we rely on the router having done it.
|
|
128
|
+
# Caveat: CoreEngine doesn't pass path_params.
|
|
129
|
+
# Workaround: Re-parse the ID from the end of the path.
|
|
130
|
+
# Assumption: standard REST path /resource/{id}
|
|
131
|
+
|
|
132
|
+
# Try to get from router params first if I updated CoreEngine? I didn't update CoreEngine to pass path_params.
|
|
133
|
+
# Let's just grab the last segment.
|
|
134
|
+
resource_id = path.rstrip('/').split('/')[-1]
|
|
135
|
+
|
|
136
|
+
collection = self._get_collection(engine)
|
|
137
|
+
for item in collection:
|
|
138
|
+
if str(item.get(self.id_field)) == resource_id:
|
|
139
|
+
return {"status_code": 200, "body": item}
|
|
140
|
+
|
|
141
|
+
return {"status_code": 404, "body": {"error": f"{self.resource_name} not found"}}
|
|
142
|
+
|
|
143
|
+
def update(self, path: str, method: str, headers: Dict, body: Any, engine: CoreEngine, query_params: Dict = None) -> Dict:
|
|
144
|
+
"""Handle PUT/PATCH /resource/{id}"""
|
|
145
|
+
resource_id = path.rstrip('/').split('/')[-1]
|
|
146
|
+
collection = self._get_collection(engine)
|
|
147
|
+
|
|
148
|
+
for i, item in enumerate(collection):
|
|
149
|
+
if str(item.get(self.id_field)) == resource_id:
|
|
150
|
+
# Merge updates
|
|
151
|
+
updated_item = item.copy()
|
|
152
|
+
if isinstance(body, dict):
|
|
153
|
+
updated_item.update(body)
|
|
154
|
+
|
|
155
|
+
# Protect ID ?? Usually yes, but this is a mock.
|
|
156
|
+
updated_item[self.id_field] = item[self.id_field]
|
|
157
|
+
|
|
158
|
+
collection[i] = updated_item
|
|
159
|
+
self._save_collection(engine, collection)
|
|
160
|
+
return {"status_code": 200, "body": updated_item}
|
|
161
|
+
|
|
162
|
+
return {"status_code": 404, "body": {"error": f"{self.resource_name} not found"}}
|
|
163
|
+
|
|
164
|
+
def delete(self, path: str, method: str, headers: Dict, body: Any, engine: CoreEngine, query_params: Dict = None) -> Dict:
|
|
165
|
+
"""Handle DELETE /resource/{id}"""
|
|
166
|
+
resource_id = path.rstrip('/').split('/')[-1]
|
|
167
|
+
collection = self._get_collection(engine)
|
|
168
|
+
|
|
169
|
+
initial_len = len(collection)
|
|
170
|
+
collection = [item for item in collection if str(item.get(self.id_field)) != resource_id]
|
|
171
|
+
|
|
172
|
+
if len(collection) < initial_len:
|
|
173
|
+
self._save_collection(engine, collection)
|
|
174
|
+
return {"status_code": 204, "body": None}
|
|
175
|
+
|
|
176
|
+
return {"status_code": 404, "body": {"error": f"{self.resource_name} not found"}}
|
api_mocker/server.py
CHANGED
|
@@ -4,15 +4,23 @@ import uvicorn
|
|
|
4
4
|
from typing import Optional, Dict, Any
|
|
5
5
|
from .core import CoreEngine, RouteConfig
|
|
6
6
|
from .config import ConfigLoader
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
7
10
|
|
|
8
11
|
class MockServer:
|
|
9
|
-
def __init__(self, config_path: Optional[str] = None):
|
|
12
|
+
def __init__(self, config_path: Optional[str] = None, config_data: Optional[Dict[str, Any]] = None):
|
|
10
13
|
self.app = FastAPI(title="api-mocker")
|
|
11
14
|
self.config_path = config_path
|
|
12
15
|
self.engine = CoreEngine()
|
|
13
16
|
self.config = {}
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
logging.basicConfig(level=logging.INFO)
|
|
19
|
+
|
|
20
|
+
if config_data:
|
|
21
|
+
self.config = config_data
|
|
22
|
+
self._apply_config()
|
|
23
|
+
elif config_path:
|
|
16
24
|
self.load_config(config_path)
|
|
17
25
|
|
|
18
26
|
self._setup_routes()
|
|
@@ -23,15 +31,15 @@ class MockServer:
|
|
|
23
31
|
self.config = ConfigLoader.load(config_path)
|
|
24
32
|
self._apply_config()
|
|
25
33
|
except Exception as e:
|
|
26
|
-
|
|
34
|
+
logger.error(f"Failed to load config {config_path}: {e}")
|
|
27
35
|
|
|
28
36
|
def _apply_config(self):
|
|
29
37
|
"""Apply configuration to the engine."""
|
|
30
38
|
routes_config = self.config.get("routes", [])
|
|
31
|
-
|
|
39
|
+
logger.info(f"Loading {len(routes_config)} routes from config")
|
|
32
40
|
|
|
33
41
|
for route_data in routes_config:
|
|
34
|
-
|
|
42
|
+
logger.info(f"Adding route: {route_data['method']} {route_data['path']}")
|
|
35
43
|
# Create a response function from the config
|
|
36
44
|
response_config = route_data.get("response", {})
|
|
37
45
|
|
|
@@ -51,12 +59,71 @@ class MockServer:
|
|
|
51
59
|
status_code=response_config.get("status_code", 200),
|
|
52
60
|
headers=response_config.get("headers"),
|
|
53
61
|
delay=route_data.get("delay", 0),
|
|
54
|
-
dynamic=route_data.get("dynamic", False)
|
|
62
|
+
dynamic=route_data.get("dynamic", False),
|
|
63
|
+
auth_required=route_data.get("auth", False)
|
|
55
64
|
)
|
|
56
65
|
self.engine.router.add_route(route)
|
|
57
66
|
|
|
58
67
|
print(f"Total routes loaded: {len(self.engine.router.routes)}")
|
|
59
68
|
|
|
69
|
+
# Process resources
|
|
70
|
+
resources_config = self.config.get("resources", [])
|
|
71
|
+
print(f"Loading {len(resources_config)} resources from config")
|
|
72
|
+
|
|
73
|
+
for res_config in resources_config:
|
|
74
|
+
name = res_config['name']
|
|
75
|
+
base_path = res_config['path']
|
|
76
|
+
id_field = res_config.get('id_field', 'id')
|
|
77
|
+
|
|
78
|
+
print(f"Adding resource: {name} at {base_path}")
|
|
79
|
+
|
|
80
|
+
# Import here to avoid circular imports
|
|
81
|
+
from .resources import ResourceHandler
|
|
82
|
+
handler = ResourceHandler(name, id_field)
|
|
83
|
+
|
|
84
|
+
# Add standard CRUD routes
|
|
85
|
+
# LIST
|
|
86
|
+
self.engine.router.add_route(RouteConfig(
|
|
87
|
+
path=base_path,
|
|
88
|
+
method="GET",
|
|
89
|
+
response=handler.list,
|
|
90
|
+
delay=res_config.get('delay', 0)
|
|
91
|
+
))
|
|
92
|
+
# CREATE
|
|
93
|
+
self.engine.router.add_route(RouteConfig(
|
|
94
|
+
path=base_path,
|
|
95
|
+
method="POST",
|
|
96
|
+
response=handler.create,
|
|
97
|
+
delay=res_config.get('delay', 0)
|
|
98
|
+
))
|
|
99
|
+
# GET Item
|
|
100
|
+
self.engine.router.add_route(RouteConfig(
|
|
101
|
+
path=f"{base_path}/{{id}}",
|
|
102
|
+
method="GET",
|
|
103
|
+
response=handler.get,
|
|
104
|
+
delay=res_config.get('delay', 0)
|
|
105
|
+
))
|
|
106
|
+
# UPDATE
|
|
107
|
+
self.engine.router.add_route(RouteConfig(
|
|
108
|
+
path=f"{base_path}/{{id}}",
|
|
109
|
+
method="PUT",
|
|
110
|
+
response=handler.update,
|
|
111
|
+
delay=res_config.get('delay', 0)
|
|
112
|
+
))
|
|
113
|
+
self.engine.router.add_route(RouteConfig(
|
|
114
|
+
path=f"{base_path}/{{id}}",
|
|
115
|
+
method="PATCH",
|
|
116
|
+
response=handler.update,
|
|
117
|
+
delay=res_config.get('delay', 0)
|
|
118
|
+
))
|
|
119
|
+
# DELETE
|
|
120
|
+
self.engine.router.add_route(RouteConfig(
|
|
121
|
+
path=f"{base_path}/{{id}}",
|
|
122
|
+
method="DELETE",
|
|
123
|
+
response=handler.delete,
|
|
124
|
+
delay=res_config.get('delay', 0)
|
|
125
|
+
))
|
|
126
|
+
|
|
60
127
|
def _setup_routes(self):
|
|
61
128
|
"""Set up FastAPI routes using the core engine."""
|
|
62
129
|
|
|
@@ -75,8 +142,11 @@ class MockServer:
|
|
|
75
142
|
except:
|
|
76
143
|
body = await request.body()
|
|
77
144
|
|
|
145
|
+
# Get query parameters
|
|
146
|
+
query_params = dict(request.query_params)
|
|
147
|
+
|
|
78
148
|
# Process request through core engine
|
|
79
|
-
response = self.engine.process_request(path, method, headers, body)
|
|
149
|
+
response = self.engine.process_request(path, method, headers, body, query_params)
|
|
80
150
|
|
|
81
151
|
# Return response
|
|
82
152
|
status_code = response.get("status_code", 200)
|