api-mocker 0.1.1__py3-none-any.whl → 0.1.3__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/__init__.py +1 -1
- api_mocker/advanced.py +391 -0
- api_mocker/analytics.py +368 -0
- api_mocker/cli.py +167 -0
- api_mocker/core.py +3 -3
- api_mocker/dashboard.py +386 -0
- api_mocker-0.1.3.dist-info/METADATA +441 -0
- api_mocker-0.1.3.dist-info/RECORD +17 -0
- api_mocker-0.1.1.dist-info/METADATA +0 -657
- api_mocker-0.1.1.dist-info/RECORD +0 -14
- {api_mocker-0.1.1.dist-info → api_mocker-0.1.3.dist-info}/WHEEL +0 -0
- {api_mocker-0.1.1.dist-info → api_mocker-0.1.3.dist-info}/entry_points.txt +0 -0
- {api_mocker-0.1.1.dist-info → api_mocker-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {api_mocker-0.1.1.dist-info → api_mocker-0.1.3.dist-info}/top_level.txt +0 -0
api_mocker/__init__.py
CHANGED
api_mocker/advanced.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Advanced features for api-mocker including rate limiting, authentication, caching, and more.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import hashlib
|
|
7
|
+
import asyncio
|
|
8
|
+
from typing import Dict, List, Optional, Any, Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from fastapi import HTTPException, Depends, Request
|
|
12
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
# Optional imports for advanced features
|
|
17
|
+
try:
|
|
18
|
+
import jwt
|
|
19
|
+
except ImportError:
|
|
20
|
+
jwt = None
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import redis
|
|
24
|
+
except ImportError:
|
|
25
|
+
redis = None
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RateLimitConfig:
|
|
31
|
+
"""Rate limiting configuration."""
|
|
32
|
+
requests_per_minute: int = 60
|
|
33
|
+
requests_per_hour: int = 1000
|
|
34
|
+
burst_size: int = 10
|
|
35
|
+
window_size: int = 60 # seconds
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class CacheConfig:
|
|
39
|
+
"""Caching configuration."""
|
|
40
|
+
enabled: bool = True
|
|
41
|
+
ttl_seconds: int = 300
|
|
42
|
+
max_size: int = 1000
|
|
43
|
+
strategy: str = "lru" # lru, fifo, random
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class AuthConfig:
|
|
47
|
+
"""Authentication configuration."""
|
|
48
|
+
enabled: bool = False
|
|
49
|
+
secret_key: str = "your-secret-key"
|
|
50
|
+
algorithm: str = "HS256"
|
|
51
|
+
token_expiry_hours: int = 24
|
|
52
|
+
require_auth: List[str] = field(default_factory=list) # List of paths that require auth
|
|
53
|
+
|
|
54
|
+
class RateLimiter:
|
|
55
|
+
"""Rate limiting implementation using sliding window."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, config: RateLimitConfig):
|
|
58
|
+
self.config = config
|
|
59
|
+
self.requests = {} # client_id -> list of timestamps
|
|
60
|
+
|
|
61
|
+
def is_allowed(self, client_id: str) -> bool:
|
|
62
|
+
"""Check if request is allowed based on rate limits."""
|
|
63
|
+
now = time.time()
|
|
64
|
+
|
|
65
|
+
if client_id not in self.requests:
|
|
66
|
+
self.requests[client_id] = []
|
|
67
|
+
|
|
68
|
+
# Remove old requests outside the window
|
|
69
|
+
window_start = now - self.config.window_size
|
|
70
|
+
self.requests[client_id] = [
|
|
71
|
+
req_time for req_time in self.requests[client_id]
|
|
72
|
+
if req_time > window_start
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
# Check if we're within limits
|
|
76
|
+
if len(self.requests[client_id]) >= self.config.requests_per_minute:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
# Add current request
|
|
80
|
+
self.requests[client_id].append(now)
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
def get_remaining_requests(self, client_id: str) -> int:
|
|
84
|
+
"""Get remaining requests for a client."""
|
|
85
|
+
now = time.time()
|
|
86
|
+
window_start = now - self.config.window_size
|
|
87
|
+
|
|
88
|
+
if client_id not in self.requests:
|
|
89
|
+
return self.config.requests_per_minute
|
|
90
|
+
|
|
91
|
+
recent_requests = len([
|
|
92
|
+
req_time for req_time in self.requests[client_id]
|
|
93
|
+
if req_time > window_start
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
return max(0, self.config.requests_per_minute - recent_requests)
|
|
97
|
+
|
|
98
|
+
class CacheManager:
|
|
99
|
+
"""Simple in-memory cache with TTL support."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, config: CacheConfig):
|
|
102
|
+
self.config = config
|
|
103
|
+
self.cache = {}
|
|
104
|
+
self.access_times = {}
|
|
105
|
+
self.max_size = config.max_size
|
|
106
|
+
|
|
107
|
+
def get(self, key: str) -> Optional[Any]:
|
|
108
|
+
"""Get value from cache."""
|
|
109
|
+
if not self.config.enabled:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
if key not in self.cache:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
# Check TTL
|
|
116
|
+
if time.time() - self.access_times[key] > self.config.ttl_seconds:
|
|
117
|
+
self.delete(key)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
# Update access time
|
|
121
|
+
self.access_times[key] = time.time()
|
|
122
|
+
return self.cache[key]
|
|
123
|
+
|
|
124
|
+
def set(self, key: str, value: Any) -> None:
|
|
125
|
+
"""Set value in cache."""
|
|
126
|
+
if not self.config.enabled:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# Evict if cache is full
|
|
130
|
+
if len(self.cache) >= self.max_size:
|
|
131
|
+
self._evict_oldest()
|
|
132
|
+
|
|
133
|
+
self.cache[key] = value
|
|
134
|
+
self.access_times[key] = time.time()
|
|
135
|
+
|
|
136
|
+
def delete(self, key: str) -> None:
|
|
137
|
+
"""Delete key from cache."""
|
|
138
|
+
self.cache.pop(key, None)
|
|
139
|
+
self.access_times.pop(key, None)
|
|
140
|
+
|
|
141
|
+
def clear(self) -> None:
|
|
142
|
+
"""Clear all cache."""
|
|
143
|
+
self.cache.clear()
|
|
144
|
+
self.access_times.clear()
|
|
145
|
+
|
|
146
|
+
def _evict_oldest(self) -> None:
|
|
147
|
+
"""Evict oldest entry based on strategy."""
|
|
148
|
+
if not self.access_times:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if self.config.strategy == "lru":
|
|
152
|
+
oldest_key = min(self.access_times.keys(), key=lambda k: self.access_times[k])
|
|
153
|
+
elif self.config.strategy == "fifo":
|
|
154
|
+
oldest_key = min(self.access_times.keys(), key=lambda k: self.access_times[k])
|
|
155
|
+
else: # random
|
|
156
|
+
import random
|
|
157
|
+
oldest_key = random.choice(list(self.access_times.keys()))
|
|
158
|
+
|
|
159
|
+
self.delete(oldest_key)
|
|
160
|
+
|
|
161
|
+
class AuthManager:
|
|
162
|
+
"""JWT-based authentication manager."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, config: AuthConfig):
|
|
165
|
+
self.config = config
|
|
166
|
+
self.security = HTTPBearer()
|
|
167
|
+
|
|
168
|
+
def create_token(self, user_id: str, roles: Optional[List[str]] = None) -> str:
|
|
169
|
+
"""Create JWT token for user."""
|
|
170
|
+
if jwt is None:
|
|
171
|
+
raise HTTPException(status_code=500, detail="JWT library not available")
|
|
172
|
+
|
|
173
|
+
payload = {
|
|
174
|
+
"user_id": user_id,
|
|
175
|
+
"roles": roles or [],
|
|
176
|
+
"exp": datetime.utcnow() + timedelta(hours=self.config.token_expiry_hours),
|
|
177
|
+
"iat": datetime.utcnow()
|
|
178
|
+
}
|
|
179
|
+
return jwt.encode(payload, self.config.secret_key, algorithm=self.config.algorithm)
|
|
180
|
+
|
|
181
|
+
def verify_token(self, token: str) -> Dict[str, Any]:
|
|
182
|
+
"""Verify JWT token and return payload."""
|
|
183
|
+
if jwt is None:
|
|
184
|
+
raise HTTPException(status_code=500, detail="JWT library not available")
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
payload = jwt.decode(token, self.config.secret_key, algorithms=[self.config.algorithm])
|
|
188
|
+
return payload
|
|
189
|
+
except jwt.ExpiredSignatureError:
|
|
190
|
+
raise HTTPException(status_code=401, detail="Token has expired")
|
|
191
|
+
except jwt.InvalidTokenError:
|
|
192
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
193
|
+
|
|
194
|
+
async def get_current_user(self, credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer())):
|
|
195
|
+
"""Dependency to get current user from token."""
|
|
196
|
+
if not self.config.enabled:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
token = credentials.credentials
|
|
200
|
+
payload = self.verify_token(token)
|
|
201
|
+
return payload
|
|
202
|
+
|
|
203
|
+
class AdvancedFeatures:
|
|
204
|
+
"""Main class for advanced features."""
|
|
205
|
+
|
|
206
|
+
def __init__(self,
|
|
207
|
+
rate_limit_config: Optional[RateLimitConfig] = None,
|
|
208
|
+
cache_config: Optional[CacheConfig] = None,
|
|
209
|
+
auth_config: Optional[AuthConfig] = None):
|
|
210
|
+
|
|
211
|
+
self.rate_limiter = RateLimiter(rate_limit_config or RateLimitConfig())
|
|
212
|
+
self.cache_manager = CacheManager(cache_config or CacheConfig())
|
|
213
|
+
self.auth_manager = AuthManager(auth_config or AuthConfig())
|
|
214
|
+
|
|
215
|
+
def get_client_id(self, request: Request) -> str:
|
|
216
|
+
"""Get client identifier for rate limiting."""
|
|
217
|
+
# Try to get from X-Forwarded-For header first
|
|
218
|
+
forwarded_for = request.headers.get("X-Forwarded-For")
|
|
219
|
+
if forwarded_for:
|
|
220
|
+
return forwarded_for.split(",")[0].strip()
|
|
221
|
+
|
|
222
|
+
# Fall back to client host
|
|
223
|
+
return request.client.host if request.client else "unknown"
|
|
224
|
+
|
|
225
|
+
def create_cache_key(self, method: str, path: str, query_params: str = "") -> str:
|
|
226
|
+
"""Create cache key for request."""
|
|
227
|
+
key_data = f"{method}:{path}:{query_params}"
|
|
228
|
+
return hashlib.md5(key_data.encode()).hexdigest()
|
|
229
|
+
|
|
230
|
+
async def rate_limit_middleware(self, request: Request, call_next):
|
|
231
|
+
"""Rate limiting middleware."""
|
|
232
|
+
client_id = self.get_client_id(request)
|
|
233
|
+
|
|
234
|
+
if not self.rate_limiter.is_allowed(client_id):
|
|
235
|
+
remaining = self.rate_limiter.get_remaining_requests(client_id)
|
|
236
|
+
raise HTTPException(
|
|
237
|
+
status_code=429,
|
|
238
|
+
detail=f"Rate limit exceeded. Try again in {60 - int(time.time() % 60)} seconds.",
|
|
239
|
+
headers={"X-RateLimit-Remaining": str(remaining)}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
response = await call_next(request)
|
|
243
|
+
|
|
244
|
+
# Add rate limit headers
|
|
245
|
+
remaining = self.rate_limiter.get_remaining_requests(client_id)
|
|
246
|
+
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
|
247
|
+
response.headers["X-RateLimit-Limit"] = str(self.rate_limiter.config.requests_per_minute)
|
|
248
|
+
|
|
249
|
+
return response
|
|
250
|
+
|
|
251
|
+
async def cache_middleware(self, request: Request, call_next):
|
|
252
|
+
"""Caching middleware."""
|
|
253
|
+
if request.method != "GET":
|
|
254
|
+
return await call_next(request)
|
|
255
|
+
|
|
256
|
+
cache_key = self.create_cache_key(
|
|
257
|
+
request.method,
|
|
258
|
+
request.url.path,
|
|
259
|
+
str(request.query_params)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Try to get from cache
|
|
263
|
+
cached_response = self.cache_manager.get(cache_key)
|
|
264
|
+
if cached_response:
|
|
265
|
+
return cached_response
|
|
266
|
+
|
|
267
|
+
# Process request
|
|
268
|
+
response = await call_next(request)
|
|
269
|
+
|
|
270
|
+
# Cache successful responses
|
|
271
|
+
if response.status_code == 200:
|
|
272
|
+
self.cache_manager.set(cache_key, response)
|
|
273
|
+
|
|
274
|
+
return response
|
|
275
|
+
|
|
276
|
+
async def auth_middleware(self, request: Request, call_next):
|
|
277
|
+
"""Authentication middleware."""
|
|
278
|
+
if not self.auth_manager.config.enabled:
|
|
279
|
+
return await call_next(request)
|
|
280
|
+
|
|
281
|
+
# Check if path requires authentication
|
|
282
|
+
if self.auth_manager.config.require_auth:
|
|
283
|
+
path_requires_auth = any(
|
|
284
|
+
request.url.path.startswith(auth_path)
|
|
285
|
+
for auth_path in self.auth_manager.config.require_auth
|
|
286
|
+
)
|
|
287
|
+
if path_requires_auth:
|
|
288
|
+
# Verify token
|
|
289
|
+
auth_header = request.headers.get("Authorization")
|
|
290
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
291
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
292
|
+
|
|
293
|
+
token = auth_header.split(" ")[1]
|
|
294
|
+
try:
|
|
295
|
+
payload = self.auth_manager.verify_token(token)
|
|
296
|
+
request.state.user = payload
|
|
297
|
+
except HTTPException:
|
|
298
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
299
|
+
|
|
300
|
+
return await call_next(request)
|
|
301
|
+
|
|
302
|
+
class MetricsCollector:
|
|
303
|
+
"""Advanced metrics collection."""
|
|
304
|
+
|
|
305
|
+
def __init__(self):
|
|
306
|
+
self.metrics = {
|
|
307
|
+
"rate_limit_hits": 0,
|
|
308
|
+
"cache_hits": 0,
|
|
309
|
+
"cache_misses": 0,
|
|
310
|
+
"auth_failures": 0,
|
|
311
|
+
"slow_queries": 0,
|
|
312
|
+
"error_counts": {}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
def increment(self, metric: str, value: int = 1):
|
|
316
|
+
"""Increment a metric."""
|
|
317
|
+
if metric in self.metrics:
|
|
318
|
+
if isinstance(self.metrics[metric], dict):
|
|
319
|
+
self.metrics[metric][str(value)] = self.metrics[metric].get(str(value), 0) + 1
|
|
320
|
+
else:
|
|
321
|
+
self.metrics[metric] += value
|
|
322
|
+
|
|
323
|
+
def get_metrics(self) -> Dict[str, Any]:
|
|
324
|
+
"""Get current metrics."""
|
|
325
|
+
return self.metrics.copy()
|
|
326
|
+
|
|
327
|
+
def reset_metrics(self):
|
|
328
|
+
"""Reset all metrics."""
|
|
329
|
+
self.metrics = {
|
|
330
|
+
"rate_limit_hits": 0,
|
|
331
|
+
"cache_hits": 0,
|
|
332
|
+
"cache_misses": 0,
|
|
333
|
+
"auth_failures": 0,
|
|
334
|
+
"slow_queries": 0,
|
|
335
|
+
"error_counts": {}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
class HealthChecker:
|
|
339
|
+
"""Health check and monitoring."""
|
|
340
|
+
|
|
341
|
+
def __init__(self):
|
|
342
|
+
self.checks = {}
|
|
343
|
+
|
|
344
|
+
def add_check(self, name: str, check_func: Callable[[], bool]):
|
|
345
|
+
"""Add a health check."""
|
|
346
|
+
self.checks[name] = check_func
|
|
347
|
+
|
|
348
|
+
def run_checks(self) -> Dict[str, bool]:
|
|
349
|
+
"""Run all health checks."""
|
|
350
|
+
results = {}
|
|
351
|
+
for name, check_func in self.checks.items():
|
|
352
|
+
try:
|
|
353
|
+
results[name] = check_func()
|
|
354
|
+
except Exception as e:
|
|
355
|
+
logger.error(f"Health check {name} failed: {e}")
|
|
356
|
+
results[name] = False
|
|
357
|
+
return results
|
|
358
|
+
|
|
359
|
+
def get_health_status(self) -> Dict[str, Any]:
|
|
360
|
+
"""Get overall health status."""
|
|
361
|
+
results = self.run_checks()
|
|
362
|
+
overall_healthy = all(results.values())
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
"status": "healthy" if overall_healthy else "unhealthy",
|
|
366
|
+
"checks": results,
|
|
367
|
+
"timestamp": datetime.utcnow().isoformat()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Predefined health checks
|
|
371
|
+
def check_database_connection():
|
|
372
|
+
"""Check database connectivity."""
|
|
373
|
+
try:
|
|
374
|
+
import sqlite3
|
|
375
|
+
conn = sqlite3.connect(":memory:")
|
|
376
|
+
conn.close()
|
|
377
|
+
return True
|
|
378
|
+
except:
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
def check_memory_usage():
|
|
382
|
+
"""Check if memory usage is acceptable."""
|
|
383
|
+
import psutil
|
|
384
|
+
memory_percent = psutil.virtual_memory().percent
|
|
385
|
+
return memory_percent < 90 # Consider healthy if < 90%
|
|
386
|
+
|
|
387
|
+
def check_disk_space():
|
|
388
|
+
"""Check if disk space is sufficient."""
|
|
389
|
+
import psutil
|
|
390
|
+
disk_percent = psutil.disk_usage('/').percent
|
|
391
|
+
return disk_percent < 95 # Consider healthy if < 95%
|