api-mocker 0.5.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 +44 -11
- api_mocker/cli.py +42 -1
- api_mocker/core.py +26 -6
- api_mocker/database_integration.py +35 -13
- api_mocker/graphql_mock.py +12 -3
- api_mocker/ml_integration.py +9 -2
- api_mocker/mock_responses.py +21 -28
- api_mocker/resources.py +176 -0
- api_mocker/server.py +77 -7
- api_mocker-0.5.1.dist-info/METADATA +782 -0
- {api_mocker-0.5.0.dist-info → api_mocker-0.5.1.dist-info}/RECORD +15 -14
- {api_mocker-0.5.0.dist-info → api_mocker-0.5.1.dist-info}/WHEEL +1 -1
- api_mocker-0.5.0.dist-info/METADATA +0 -477
- {api_mocker-0.5.0.dist-info → api_mocker-0.5.1.dist-info}/entry_points.txt +0 -0
- {api_mocker-0.5.0.dist-info → api_mocker-0.5.1.dist-info}/licenses/LICENSE +0 -0
- {api_mocker-0.5.0.dist-info → api_mocker-0.5.1.dist-info}/top_level.txt +0 -0
api_mocker/auth_system.py
CHANGED
|
@@ -18,13 +18,31 @@ import time
|
|
|
18
18
|
from typing import Any, Dict, List, Optional, Union, Callable
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
20
|
from enum import Enum
|
|
21
|
-
from datetime import datetime, timedelta
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
22
|
import json
|
|
23
23
|
import base64
|
|
24
24
|
import hmac
|
|
25
25
|
import pyotp
|
|
26
26
|
import qrcode
|
|
27
27
|
from io import BytesIO
|
|
28
|
+
import bcrypt # Replaces passlib
|
|
29
|
+
from pydantic import BaseModel, EmailStr, Field, validator
|
|
30
|
+
import os
|
|
31
|
+
|
|
32
|
+
# Pydantic Models
|
|
33
|
+
class UserCreate(BaseModel):
|
|
34
|
+
username: str = Field(..., min_length=3, max_length=50)
|
|
35
|
+
email: EmailStr
|
|
36
|
+
password: str = Field(..., min_length=8)
|
|
37
|
+
|
|
38
|
+
class UserLogin(BaseModel):
|
|
39
|
+
email: EmailStr
|
|
40
|
+
password: str
|
|
41
|
+
|
|
42
|
+
class TokenSchema(BaseModel):
|
|
43
|
+
access_token: str
|
|
44
|
+
refresh_token: str
|
|
45
|
+
token_type: str
|
|
28
46
|
|
|
29
47
|
|
|
30
48
|
class AuthProvider(Enum):
|
|
@@ -198,7 +216,7 @@ class JWTManager:
|
|
|
198
216
|
def create_token(self, user_id: str, token_type: TokenType,
|
|
199
217
|
expires_in: int = 3600, **kwargs) -> str:
|
|
200
218
|
"""Create a JWT token"""
|
|
201
|
-
now = datetime.
|
|
219
|
+
now = datetime.now(timezone.utc)
|
|
202
220
|
payload = {
|
|
203
221
|
"user_id": user_id,
|
|
204
222
|
"token_type": token_type.value,
|
|
@@ -259,10 +277,15 @@ class AdvancedAuthSystem:
|
|
|
259
277
|
"""Main authentication system"""
|
|
260
278
|
|
|
261
279
|
def __init__(self, secret_key: str = None):
|
|
262
|
-
|
|
280
|
+
# Use simple environment variable check for secret key or generate safe random one
|
|
281
|
+
self.secret_key = secret_key or os.getenv("API_MOCKER_SECRET_KEY") or secrets.token_hex(32)
|
|
282
|
+
if not secret_key and not os.getenv("API_MOCKER_SECRET_KEY"):
|
|
283
|
+
print("WARNING: Using random secret key. Sessions will be invalidated on restart.")
|
|
284
|
+
|
|
263
285
|
self.jwt_manager = JWTManager(self.secret_key)
|
|
264
286
|
self.mfa_handler = MFAHandler()
|
|
265
287
|
self.password_validator = PasswordValidator()
|
|
288
|
+
# Removed pwd_context (passlib)
|
|
266
289
|
|
|
267
290
|
# Storage
|
|
268
291
|
self.users: Dict[str, User] = {}
|
|
@@ -277,24 +300,27 @@ class AdvancedAuthSystem:
|
|
|
277
300
|
self.lockout_duration = 300 # 5 minutes
|
|
278
301
|
|
|
279
302
|
def hash_password(self, password: str) -> str:
|
|
280
|
-
"""Hash a password using
|
|
281
|
-
salt =
|
|
282
|
-
|
|
283
|
-
return
|
|
303
|
+
"""Hash a password using bcrypt"""
|
|
304
|
+
salt = bcrypt.gensalt()
|
|
305
|
+
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
|
|
306
|
+
return hashed.decode('utf-8')
|
|
284
307
|
|
|
285
308
|
def verify_password(self, password: str, password_hash: str) -> bool:
|
|
286
309
|
"""Verify a password against its hash"""
|
|
287
310
|
try:
|
|
288
|
-
|
|
289
|
-
pwd_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
|
|
290
|
-
return hmac.compare_digest(hash_hex, pwd_hash.hex())
|
|
311
|
+
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
|
291
312
|
except ValueError:
|
|
292
313
|
return False
|
|
293
314
|
|
|
294
315
|
def register_user(self, username: str, email: str, password: str,
|
|
295
316
|
roles: List[UserRole] = None) -> Dict[str, Any]:
|
|
296
317
|
"""Register a new user"""
|
|
297
|
-
|
|
318
|
+
try:
|
|
319
|
+
user_data = UserCreate(username=username, email=email, password=password)
|
|
320
|
+
except Exception as e:
|
|
321
|
+
return {"success": False, "error": str(e)}
|
|
322
|
+
|
|
323
|
+
# Validate password strength logic (keep existing validator for complexity rules)
|
|
298
324
|
password_validation = self.password_validator.validate(password)
|
|
299
325
|
if not password_validation["is_valid"]:
|
|
300
326
|
return {
|
|
@@ -330,6 +356,13 @@ class AdvancedAuthSystem:
|
|
|
330
356
|
def authenticate_user(self, email: str, password: str,
|
|
331
357
|
ip_address: str = None, user_agent: str = None) -> Dict[str, Any]:
|
|
332
358
|
"""Authenticate a user with email and password"""
|
|
359
|
+
# Validate input using Pydantic model
|
|
360
|
+
try:
|
|
361
|
+
# validators will check email format
|
|
362
|
+
login_data = UserLogin(email=email, password=password)
|
|
363
|
+
except Exception as e:
|
|
364
|
+
return {"success": False, "error": str(e)}
|
|
365
|
+
|
|
333
366
|
# Check rate limiting
|
|
334
367
|
if self._is_rate_limited(email):
|
|
335
368
|
return {"success": False, "error": "Too many login attempts"}
|
api_mocker/cli.py
CHANGED
|
@@ -60,6 +60,47 @@ def start(
|
|
|
60
60
|
|
|
61
61
|
server.start(host=host, port=port)
|
|
62
62
|
|
|
63
|
+
@app.command()
|
|
64
|
+
def list_routes(
|
|
65
|
+
config: str = typer.Option(..., "--config", "-c", help="Path to mock server config file"),
|
|
66
|
+
):
|
|
67
|
+
"""List all configured routes."""
|
|
68
|
+
try:
|
|
69
|
+
server = MockServer(config_path=config)
|
|
70
|
+
# Assuming server.engine.router gets populated on init or we need to load manually
|
|
71
|
+
# server.app is FastAPI, we can inspect routes there if loaded.
|
|
72
|
+
# But MockServer logic loads config in __init__?
|
|
73
|
+
# Let's check MockServer implementation in server.py if needed.
|
|
74
|
+
# Assuming server._load_config() is called in __init__.
|
|
75
|
+
|
|
76
|
+
table = Table(title="Configured Routes")
|
|
77
|
+
table.add_column("Method", style="cyan")
|
|
78
|
+
table.add_column("Path", style="green")
|
|
79
|
+
table.add_column("Auth", style="red")
|
|
80
|
+
|
|
81
|
+
# We might need to access the config directly if server doesn't expose routes easily without starting
|
|
82
|
+
# But server.engine.router.routes should exist.
|
|
83
|
+
# Let's just read the file directly for now to be safe and simple,
|
|
84
|
+
# as MockServer might require starting to register FastAPI routes fully.
|
|
85
|
+
# Actually server.engine.router.routes is populated in _apply_config
|
|
86
|
+
|
|
87
|
+
# Re-implementing config read for visibility
|
|
88
|
+
with open(config, 'r') as f:
|
|
89
|
+
if config.endswith('.yaml') or config.endswith('.yml'):
|
|
90
|
+
conf_data = yaml.safe_load(f)
|
|
91
|
+
else:
|
|
92
|
+
conf_data = json.load(f)
|
|
93
|
+
|
|
94
|
+
for route in conf_data.get("routes", []):
|
|
95
|
+
auth_str = "Yes" if route.get("auth", False) else "No"
|
|
96
|
+
table.add_row(route.get("method", "GET"), route.get("path"), auth_str)
|
|
97
|
+
|
|
98
|
+
console.print(table)
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
console.print(f"[red]✗[/red] Failed to list routes: {e}")
|
|
102
|
+
raise typer.Exit(1)
|
|
103
|
+
|
|
63
104
|
@app.command()
|
|
64
105
|
def import_spec(
|
|
65
106
|
file_path: str = typer.Argument(..., help="Path to OpenAPI/Postman file"),
|
|
@@ -618,7 +659,7 @@ def advanced(
|
|
|
618
659
|
|
|
619
660
|
config = AuthConfig(
|
|
620
661
|
enabled=True,
|
|
621
|
-
secret_key="your-secret-key-change-this",
|
|
662
|
+
secret_key=os.getenv("API_MOCKER_SECRET_KEY", "your-secret-key-change-this"),
|
|
622
663
|
algorithm="HS256",
|
|
623
664
|
token_expiry_hours=24
|
|
624
665
|
)
|
api_mocker/core.py
CHANGED
|
@@ -10,11 +10,13 @@ import uuid
|
|
|
10
10
|
class RouteConfig:
|
|
11
11
|
path: str
|
|
12
12
|
method: str
|
|
13
|
-
response:
|
|
13
|
+
response: Any
|
|
14
14
|
status_code: int = 200
|
|
15
|
-
headers:
|
|
15
|
+
headers: Dict = None
|
|
16
16
|
delay: float = 0
|
|
17
17
|
dynamic: bool = False
|
|
18
|
+
weight: int = 100
|
|
19
|
+
auth_required: bool = False
|
|
18
20
|
|
|
19
21
|
class DynamicResponseGenerator:
|
|
20
22
|
"""Generates realistic fake data for API responses."""
|
|
@@ -208,7 +210,7 @@ class CoreEngine:
|
|
|
208
210
|
def add_middleware(self, middleware_func: Callable):
|
|
209
211
|
self.middleware.append(middleware_func)
|
|
210
212
|
|
|
211
|
-
def process_request(self, path: str, method: str, headers: Dict, body: Any = None) -> Dict:
|
|
213
|
+
def process_request(self, path: str, method: str, headers: Dict, body: Any = None, query_params: Dict = None) -> Dict:
|
|
212
214
|
# Apply middleware
|
|
213
215
|
for middleware in self.middleware:
|
|
214
216
|
path, method, headers, body = middleware(path, method, headers, body)
|
|
@@ -218,8 +220,20 @@ class CoreEngine:
|
|
|
218
220
|
if not route:
|
|
219
221
|
return {"status_code": 404, "body": {"error": "Route not found"}}
|
|
220
222
|
|
|
223
|
+
# Check Authentication
|
|
224
|
+
if route.auth_required:
|
|
225
|
+
from .auth_system import auth_system
|
|
226
|
+
auth_header = headers.get("authorization") or headers.get("Authorization")
|
|
227
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
228
|
+
return {"status_code": 401, "body": {"error": "Missing or invalid token"}}
|
|
229
|
+
|
|
230
|
+
token = auth_header.split(" ")[1]
|
|
231
|
+
validation = auth_system.verify_token(token)
|
|
232
|
+
if not validation["valid"]:
|
|
233
|
+
return {"status_code": 401, "body": {"error": validation.get("error", "Invalid token")}}
|
|
234
|
+
|
|
221
235
|
# Generate response
|
|
222
|
-
response = self._generate_response(route, path, method, headers, body)
|
|
236
|
+
response = self._generate_response(route, path, method, headers, body, query_params)
|
|
223
237
|
|
|
224
238
|
# Apply delay if specified
|
|
225
239
|
if route.delay > 0:
|
|
@@ -228,10 +242,16 @@ class CoreEngine:
|
|
|
228
242
|
|
|
229
243
|
return response
|
|
230
244
|
|
|
231
|
-
def _generate_response(self, route: RouteConfig, path: str, method: str, headers: Dict, body: Any) -> Dict:
|
|
245
|
+
def _generate_response(self, route: RouteConfig, path: str, method: str, headers: Dict, body: Any, query_params: Dict = None) -> Dict:
|
|
232
246
|
if callable(route.response):
|
|
247
|
+
# Check if the callable accepts query_params
|
|
248
|
+
import inspect
|
|
249
|
+
sig = inspect.signature(route.response)
|
|
250
|
+
if 'query_params' in sig.parameters:
|
|
251
|
+
return route.response(path, method, headers, body, self, query_params)
|
|
233
252
|
return route.response(path, method, headers, body, self)
|
|
234
253
|
elif isinstance(route.response, str):
|
|
235
254
|
return {"status_code": route.status_code, "body": route.response}
|
|
236
255
|
else:
|
|
237
|
-
return {"status_code": route.status_code, "body": route.response}
|
|
256
|
+
return {"status_code": route.status_code, "body": route.response}
|
|
257
|
+
|
|
@@ -173,8 +173,9 @@ class SQLiteManager:
|
|
|
173
173
|
query = f"DELETE FROM {table_name} WHERE {where_clause}"
|
|
174
174
|
return await self.execute_update(query, params)
|
|
175
175
|
|
|
176
|
+
|
|
176
177
|
def _build_where_clause(self, conditions: List[QueryCondition]) -> Tuple[str, Tuple]:
|
|
177
|
-
"""Build WHERE clause from conditions"""
|
|
178
|
+
"""Build WHERE clause from conditions, returning parameterized query and params"""
|
|
178
179
|
if not conditions:
|
|
179
180
|
return "1=1", ()
|
|
180
181
|
|
|
@@ -182,22 +183,43 @@ class SQLiteManager:
|
|
|
182
183
|
params = []
|
|
183
184
|
|
|
184
185
|
for i, condition in enumerate(conditions):
|
|
186
|
+
# Only add logical operator if it's not the first condition
|
|
185
187
|
if i > 0:
|
|
186
|
-
|
|
188
|
+
# Validate logical operator to prevent injection
|
|
189
|
+
op = condition.logical_operator.upper()
|
|
190
|
+
if op not in ["AND", "OR"]:
|
|
191
|
+
op = "AND"
|
|
192
|
+
clauses.append(op)
|
|
187
193
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
# Map operators to SQL
|
|
195
|
+
op_map = {
|
|
196
|
+
QueryOperator.EQ: "=",
|
|
197
|
+
QueryOperator.NE: "!=",
|
|
198
|
+
QueryOperator.GT: ">",
|
|
199
|
+
QueryOperator.GTE: ">=",
|
|
200
|
+
QueryOperator.LT: "<",
|
|
201
|
+
QueryOperator.LTE: "<=",
|
|
202
|
+
QueryOperator.LIKE: "LIKE"
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if condition.operator in op_map:
|
|
206
|
+
clauses.append(f"{condition.field} {op_map[condition.operator]} ?")
|
|
196
207
|
params.append(condition.value)
|
|
197
208
|
elif condition.operator == QueryOperator.IN:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
209
|
+
# Handle IN operator safely
|
|
210
|
+
if not condition.value:
|
|
211
|
+
clauses.append("1=0") # Empty list matches nothing
|
|
212
|
+
else:
|
|
213
|
+
placeholders = ",".join(["?" for _ in condition.value])
|
|
214
|
+
clauses.append(f"{condition.field} IN ({placeholders})")
|
|
215
|
+
params.extend(condition.value)
|
|
216
|
+
elif condition.operator == QueryOperator.NOT_IN:
|
|
217
|
+
if not condition.value:
|
|
218
|
+
clauses.append("1=1") # NOT IN empty list is always true
|
|
219
|
+
else:
|
|
220
|
+
placeholders = ",".join(["?" for _ in condition.value])
|
|
221
|
+
clauses.append(f"{condition.field} NOT IN ({placeholders})")
|
|
222
|
+
params.extend(condition.value)
|
|
201
223
|
elif condition.operator == QueryOperator.IS_NULL:
|
|
202
224
|
clauses.append(f"{condition.field} IS NULL")
|
|
203
225
|
elif condition.operator == QueryOperator.IS_NOT_NULL:
|
api_mocker/graphql_mock.py
CHANGED
|
@@ -468,9 +468,18 @@ class GraphQLMockServer:
|
|
|
468
468
|
return {key: self._substitute_variables(value, variables) for key, value in data.items()}
|
|
469
469
|
elif isinstance(data, list):
|
|
470
470
|
return [self._substitute_variables(item, variables) for item in data]
|
|
471
|
-
elif isinstance(data, str)
|
|
472
|
-
|
|
473
|
-
|
|
471
|
+
elif isinstance(data, str):
|
|
472
|
+
# Handle full replacement (preserving type if variable is not string)
|
|
473
|
+
if data.startswith('{{') and data.endswith('}}'):
|
|
474
|
+
var_name = data[2:-2]
|
|
475
|
+
if var_name in variables:
|
|
476
|
+
return variables[var_name]
|
|
477
|
+
|
|
478
|
+
# Handle partial replacement (string interpolation)
|
|
479
|
+
if '{{' in data and '}}' in data:
|
|
480
|
+
for key, value in variables.items():
|
|
481
|
+
data = data.replace(f"{{{{{key}}}}}", str(value))
|
|
482
|
+
return data
|
|
474
483
|
else:
|
|
475
484
|
return data
|
|
476
485
|
|
api_mocker/ml_integration.py
CHANGED
|
@@ -117,7 +117,7 @@ class FeatureExtractor:
|
|
|
117
117
|
features['has_path_params'] = 1 if '{' in request_data.get('path', '') else 0
|
|
118
118
|
|
|
119
119
|
# Header features
|
|
120
|
-
headers = request_data.get('headers', {})
|
|
120
|
+
headers = {k.lower(): v for k, v in request_data.get('headers', {}).items()}
|
|
121
121
|
features['header_count'] = len(headers)
|
|
122
122
|
features['has_auth_header'] = 1 if 'authorization' in headers else 0
|
|
123
123
|
features['has_content_type'] = 1 if 'content-type' in headers else 0
|
|
@@ -225,7 +225,14 @@ class MLModelManager:
|
|
|
225
225
|
|
|
226
226
|
# Evaluate
|
|
227
227
|
y_pred = model.model.predict(X_test_scaled)
|
|
228
|
-
|
|
228
|
+
|
|
229
|
+
if model.model_type == MLModelType.REGRESSION:
|
|
230
|
+
from sklearn.metrics import r2_score
|
|
231
|
+
# R2 score can be negative, but it runs.
|
|
232
|
+
# For very simple/random data, it might be poor.
|
|
233
|
+
accuracy = r2_score(y_test, y_pred)
|
|
234
|
+
else:
|
|
235
|
+
accuracy = accuracy_score(y_test, y_pred)
|
|
229
236
|
|
|
230
237
|
# Update model
|
|
231
238
|
model.accuracy = accuracy
|
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"}}
|