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 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.utcnow()
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
- self.secret_key = secret_key or secrets.token_hex(32)
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 PBKDF2"""
281
- salt = secrets.token_hex(16)
282
- pwd_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
283
- return f"{salt}:{pwd_hash.hex()}"
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
- salt, hash_hex = password_hash.split(':')
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
- # Validate password
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: Union[Dict, Callable, str]
13
+ response: Any
14
14
  status_code: int = 200
15
- headers: Optional[Dict[str, str]] = None
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
- clauses.append(condition.logical_operator)
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
- if condition.operator == QueryOperator.EQ:
189
- clauses.append(f"{condition.field} = ?")
190
- params.append(condition.value)
191
- elif condition.operator == QueryOperator.NE:
192
- clauses.append(f"{condition.field} != ?")
193
- params.append(condition.value)
194
- elif condition.operator == QueryOperator.LIKE:
195
- clauses.append(f"{condition.field} LIKE ?")
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
- placeholders = ",".join(["?" for _ in condition.value])
199
- clauses.append(f"{condition.field} IN ({placeholders})")
200
- params.extend(condition.value)
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:
@@ -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) and data.startswith('{{') and data.endswith('}}'):
472
- var_name = data[2:-2]
473
- return variables.get(var_name, data)
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
 
@@ -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
- accuracy = accuracy_score(y_test, y_pred)
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
@@ -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
- request_body: Request body
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
- request_body = kwargs.get('body')
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
- 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
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"""
@@ -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"}}