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.
@@ -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"}}
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
- if config_path:
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
- print(f"Warning: Failed to load config {config_path}: {e}")
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
- print(f"Loading {len(routes_config)} routes from config")
39
+ logger.info(f"Loading {len(routes_config)} routes from config")
32
40
 
33
41
  for route_data in routes_config:
34
- print(f"Adding route: {route_data['method']} {route_data['path']}")
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)