maleo-foundation 0.2.78__py3-none-any.whl → 0.2.81__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.
- maleo_foundation/managers/service.py +3 -2
- maleo_foundation/middlewares/base.py +345 -254
- maleo_foundation/models/transfers/general/__init__.py +25 -1
- maleo_foundation/utils/dependencies/context.py +9 -0
- maleo_foundation/utils/extractor.py +53 -18
- {maleo_foundation-0.2.78.dist-info → maleo_foundation-0.2.81.dist-info}/METADATA +1 -1
- {maleo_foundation-0.2.78.dist-info → maleo_foundation-0.2.81.dist-info}/RECORD +9 -8
- {maleo_foundation-0.2.78.dist-info → maleo_foundation-0.2.81.dist-info}/WHEEL +0 -0
- {maleo_foundation-0.2.78.dist-info → maleo_foundation-0.2.81.dist-info}/top_level.txt +0 -0
@@ -90,6 +90,7 @@ class MaleoClientConfiguration(BaseModel):
|
|
90
90
|
url:str = Field(..., description="Client's URL")
|
91
91
|
|
92
92
|
class MaleoClientConfigurations(BaseModel):
|
93
|
+
telemetry:MaleoClientConfiguration = Field(..., description="MaleoTelemetry client's configuration")
|
93
94
|
metadata:MaleoClientConfiguration = Field(..., description="MaleoMetadata client's configuration")
|
94
95
|
identity:MaleoClientConfiguration = Field(..., description="MaleoIdentity client's configuration")
|
95
96
|
access:MaleoClientConfiguration = Field(..., description="MaleoAccess client's configuration")
|
@@ -415,7 +416,7 @@ class ServiceManager:
|
|
415
416
|
maleo_foundation=self._foundation
|
416
417
|
)
|
417
418
|
self._middleware.add_all()
|
418
|
-
self._loggers.application.info("Middlewares
|
419
|
+
self._loggers.application.info("Middlewares added successfully")
|
419
420
|
|
420
421
|
#* Add exception handler(s)
|
421
422
|
self._loggers.application.info("Adding exception handlers")
|
@@ -427,7 +428,7 @@ class ServiceManager:
|
|
427
428
|
exc_class_or_status_code=HTTPException,
|
428
429
|
handler=BaseExceptions.http_exception_handler
|
429
430
|
)
|
430
|
-
self._loggers.application.info("Exception handlers
|
431
|
+
self._loggers.application.info("Exception handlers added successfully")
|
431
432
|
|
432
433
|
#* Include router
|
433
434
|
self._loggers.application.info("Including routers")
|
@@ -4,303 +4,398 @@ import time
|
|
4
4
|
import traceback
|
5
5
|
from collections import defaultdict
|
6
6
|
from datetime import datetime, timedelta, timezone
|
7
|
+
from typing import Awaitable, Callable, Optional, Sequence, Dict, List
|
8
|
+
|
7
9
|
from fastapi import FastAPI, Request, Response, status
|
8
10
|
from fastapi.responses import JSONResponse
|
9
11
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
10
|
-
|
12
|
+
|
11
13
|
from maleo_foundation.authentication import Authentication
|
12
14
|
from maleo_foundation.enums import BaseEnums
|
13
15
|
from maleo_foundation.client.manager import MaleoFoundationClientManager
|
14
16
|
from maleo_foundation.models.schemas import BaseGeneralSchemas
|
15
17
|
from maleo_foundation.models.responses import BaseResponses
|
16
|
-
from maleo_foundation.models.transfers.general.token
|
17
|
-
|
18
|
-
from maleo_foundation.models.transfers.parameters.
|
19
|
-
|
20
|
-
from maleo_foundation.
|
21
|
-
import MaleoFoundationSignatureParametersTransfers
|
22
|
-
from maleo_foundation.utils.extractor import BaseExtractors
|
18
|
+
from maleo_foundation.models.transfers.general.token import MaleoFoundationTokenGeneralTransfers
|
19
|
+
from maleo_foundation.models.transfers.parameters.token import MaleoFoundationTokenParametersTransfers
|
20
|
+
from maleo_foundation.models.transfers.parameters.signature import MaleoFoundationSignatureParametersTransfers
|
21
|
+
from maleo_foundation.models.transfers.general import RequestContextTransfers
|
22
|
+
from maleo_foundation.utils.extractor import extract_request_context
|
23
23
|
from maleo_foundation.utils.logging import MiddlewareLogger
|
24
24
|
|
25
25
|
RequestProcessor = Callable[[Request], Awaitable[Optional[Response]]]
|
26
26
|
ResponseProcessor = Callable[[Response], Awaitable[Response]]
|
27
27
|
|
28
|
-
class
|
28
|
+
class RateLimiter:
|
29
|
+
"""Thread-safe rate limiter with automatic cleanup."""
|
30
|
+
|
29
31
|
def __init__(
|
30
32
|
self,
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
allow_origins:Sequence[str] = (),
|
36
|
-
allow_methods:Sequence[str] = ("GET",),
|
37
|
-
allow_headers:Sequence[str] = (),
|
38
|
-
allow_credentials:bool = False,
|
39
|
-
limit:int = 10,
|
40
|
-
window:int = 1,
|
41
|
-
cleanup_interval:int = 60,
|
42
|
-
ip_timeout:int = 300
|
33
|
+
limit:int,
|
34
|
+
window:timedelta,
|
35
|
+
ip_timeout:timedelta,
|
36
|
+
cleanup_interval:timedelta
|
43
37
|
):
|
44
|
-
|
45
|
-
self.
|
46
|
-
self.
|
47
|
-
self.
|
48
|
-
self.
|
49
|
-
self.
|
50
|
-
self._allow_headers = allow_headers
|
51
|
-
self._allow_credentials = allow_credentials
|
52
|
-
self._limit = limit
|
53
|
-
self._window = timedelta(seconds=window)
|
54
|
-
self._cleanup_interval = timedelta(seconds=cleanup_interval)
|
55
|
-
self._ip_timeout = timedelta(seconds=ip_timeout)
|
56
|
-
self._requests:dict[str, list[datetime]] = defaultdict(list)
|
57
|
-
self._last_seen: dict[str, datetime] = {}
|
38
|
+
self.limit = limit
|
39
|
+
self.window = window
|
40
|
+
self.ip_timeout = ip_timeout
|
41
|
+
self.cleanup_interval = cleanup_interval
|
42
|
+
self._requests:Dict[str, List[datetime]] = defaultdict(list)
|
43
|
+
self._last_seen:Dict[str, datetime] = {}
|
58
44
|
self._last_cleanup = datetime.now()
|
59
|
-
self._lock = threading.RLock()
|
60
|
-
|
61
|
-
def
|
62
|
-
"""
|
63
|
-
Periodically clean up old request data to prevent memory growth.
|
64
|
-
Removes:
|
65
|
-
1. IPs with empty request lists
|
66
|
-
2. IPs that haven't been seen in ip_timeout period
|
67
|
-
"""
|
68
|
-
now = datetime.now()
|
69
|
-
if now - self._last_cleanup > self._cleanup_interval:
|
70
|
-
with self._lock:
|
71
|
-
#* Remove inactive IPs (not seen recently) and empty lists
|
72
|
-
inactive_ips = []
|
73
|
-
for ip in list(self._requests.keys()):
|
74
|
-
#* Remove IPs with empty timestamp lists
|
75
|
-
if not self._requests[ip]:
|
76
|
-
inactive_ips.append(ip)
|
77
|
-
continue
|
78
|
-
|
79
|
-
#* Remove IPs that haven't been active recently
|
80
|
-
last_active = self._last_seen.get(ip, datetime.min)
|
81
|
-
if now - last_active > self._ip_timeout:
|
82
|
-
inactive_ips.append(ip)
|
83
|
-
|
84
|
-
#* Remove the inactive IPs
|
85
|
-
for ip in inactive_ips:
|
86
|
-
if ip in self._requests:
|
87
|
-
del self._requests[ip]
|
88
|
-
if ip in self._last_seen:
|
89
|
-
del self._last_seen[ip]
|
90
|
-
|
91
|
-
# Update last cleanup time
|
92
|
-
self._last_cleanup = now
|
93
|
-
self._logger.debug(f"Cleaned up request cache. Removed {len(inactive_ips)} inactive IPs. Current tracked IPs: {len(self._requests)}")
|
94
|
-
|
95
|
-
def _check_rate_limit(
|
45
|
+
self._lock = threading.RLock()
|
46
|
+
|
47
|
+
def is_rate_limited(
|
96
48
|
self,
|
97
|
-
|
49
|
+
request_context:RequestContextTransfers
|
98
50
|
) -> bool:
|
99
|
-
"""Check if
|
51
|
+
"""Check if client IP is rate limited and record the request."""
|
100
52
|
with self._lock:
|
101
|
-
now = datetime.now()
|
102
|
-
|
53
|
+
now = datetime.now()
|
54
|
+
client_ip = request_context.ip_address
|
55
|
+
self._last_seen[client_ip] = now
|
103
56
|
|
104
|
-
|
57
|
+
# Remove old requests outside the window
|
105
58
|
self._requests[client_ip] = [
|
106
59
|
timestamp for timestamp in self._requests[client_ip]
|
107
|
-
if now - timestamp <= self.
|
60
|
+
if now - timestamp <= self.window
|
108
61
|
]
|
109
62
|
|
110
|
-
|
111
|
-
if len(self._requests[client_ip]) >= self.
|
63
|
+
# Check rate limit
|
64
|
+
if len(self._requests[client_ip]) >= self.limit:
|
112
65
|
return True
|
113
66
|
|
114
|
-
|
67
|
+
# Record this request
|
115
68
|
self._requests[client_ip].append(now)
|
116
69
|
return False
|
117
70
|
|
118
|
-
def
|
71
|
+
def cleanup_old_data(
|
72
|
+
self,
|
73
|
+
logger:MiddlewareLogger
|
74
|
+
) -> None:
|
75
|
+
"""Clean up old request data to prevent memory growth."""
|
76
|
+
now = datetime.now()
|
77
|
+
if now - self._last_cleanup <= self.cleanup_interval:
|
78
|
+
return
|
79
|
+
|
80
|
+
with self._lock:
|
81
|
+
inactive_ips = []
|
82
|
+
|
83
|
+
for ip in list(self._requests.keys()):
|
84
|
+
# Remove IPs with empty request lists
|
85
|
+
if not self._requests[ip]:
|
86
|
+
inactive_ips.append(ip)
|
87
|
+
continue
|
88
|
+
|
89
|
+
# Remove IPs that haven't been active recently
|
90
|
+
last_active = self._last_seen.get(ip, datetime.min)
|
91
|
+
if now - last_active > self.ip_timeout:
|
92
|
+
inactive_ips.append(ip)
|
93
|
+
|
94
|
+
# Clean up inactive IPs
|
95
|
+
for ip in inactive_ips:
|
96
|
+
self._requests.pop(ip, None)
|
97
|
+
self._last_seen.pop(ip, None)
|
98
|
+
|
99
|
+
self._last_cleanup = now
|
100
|
+
logger.debug(
|
101
|
+
f"Cleaned up request cache. Removed {len(inactive_ips)} inactive IPs. "
|
102
|
+
f"Current tracked IPs: {len(self._requests)}"
|
103
|
+
)
|
104
|
+
|
105
|
+
class ResponseBuilder:
|
106
|
+
"""Handles response building and header management."""
|
107
|
+
|
108
|
+
def __init__(
|
109
|
+
self,
|
110
|
+
keys:BaseGeneralSchemas.RSAKeys,
|
111
|
+
maleo_foundation:MaleoFoundationClientManager
|
112
|
+
):
|
113
|
+
self.keys = keys
|
114
|
+
self.maleo_foundation = maleo_foundation
|
115
|
+
|
116
|
+
def add_response_headers(
|
119
117
|
self,
|
120
|
-
request:Request,
|
121
118
|
authentication:Authentication,
|
122
119
|
response:Response,
|
123
|
-
|
124
|
-
|
125
|
-
process_time:
|
120
|
+
request_context:RequestContextTransfers,
|
121
|
+
responded_at:datetime,
|
122
|
+
process_time:float
|
126
123
|
) -> Response:
|
127
|
-
|
128
|
-
|
129
|
-
response.headers["X-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
124
|
+
"""Add custom headers to response."""
|
125
|
+
# Basic headers
|
126
|
+
response.headers["X-Request-ID"] = str(request_context.request_id)
|
127
|
+
response.headers["X-Process-Time"] = str(process_time)
|
128
|
+
response.headers["X-Requested-At"] = request_context.requested_at.isoformat()
|
129
|
+
response.headers["X-Responded-At"] = responded_at.isoformat()
|
130
|
+
|
131
|
+
# Add signature header
|
132
|
+
self._add_signature_header(response, request_context, responded_at, process_time)
|
133
|
+
|
134
|
+
# Add new authorization header if needed
|
135
|
+
self._add_new_authorization_header(request_context, authentication, response)
|
136
|
+
|
137
|
+
return response
|
138
|
+
|
139
|
+
def _add_signature_header(
|
140
|
+
self,
|
141
|
+
response:Response,
|
142
|
+
request_context:RequestContextTransfers,
|
143
|
+
responded_at:datetime,
|
144
|
+
process_time:float
|
145
|
+
) -> None:
|
146
|
+
"""Generate and add signature header."""
|
147
|
+
message = (
|
148
|
+
f"{request_context.method}|{request_context.url}|{request_context.requested_at.isoformat()}|"
|
149
|
+
f"{responded_at.isoformat()}|{str(process_time)}|{str(request_context.request_id)}"
|
150
|
+
)
|
151
|
+
|
152
|
+
sign_parameters = MaleoFoundationSignatureParametersTransfers.Sign(
|
153
|
+
key=self.keys.private,
|
154
|
+
password=self.keys.password,
|
155
|
+
message=message
|
135
156
|
)
|
136
|
-
|
157
|
+
|
158
|
+
sign_result = self.maleo_foundation.services.signature.sign(parameters=sign_parameters)
|
137
159
|
if sign_result.success:
|
138
160
|
response.headers["X-Signature"] = sign_result.data.signature
|
139
|
-
|
161
|
+
|
162
|
+
def _add_new_authorization_header(
|
163
|
+
self,
|
164
|
+
request_context:RequestContextTransfers,
|
165
|
+
authentication:Authentication,
|
166
|
+
response:Response
|
167
|
+
) -> None:
|
168
|
+
"""Add new authorization header for refresh tokens."""
|
169
|
+
if not self._should_regenerate_auth(request_context, authentication, response):
|
170
|
+
return
|
171
|
+
|
172
|
+
payload = MaleoFoundationTokenGeneralTransfers.BaseEncodePayload.model_validate(
|
173
|
+
authentication.credentials.token.payload.model_dump()
|
174
|
+
)
|
175
|
+
|
176
|
+
parameters = MaleoFoundationTokenParametersTransfers.Encode(
|
177
|
+
key=self.keys.private,
|
178
|
+
password=self.keys.password,
|
179
|
+
payload=payload
|
180
|
+
)
|
181
|
+
|
182
|
+
result = self.maleo_foundation.services.token.encode(parameters=parameters)
|
183
|
+
if result.success:
|
184
|
+
response.headers["X-New-Authorization"] = result.data.token
|
185
|
+
|
186
|
+
def _should_regenerate_auth(
|
187
|
+
self,
|
188
|
+
request_context:RequestContextTransfers,
|
189
|
+
authentication:Authentication,
|
190
|
+
response:Response
|
191
|
+
) -> bool:
|
192
|
+
"""Check if authorization should be regenerated."""
|
193
|
+
return (
|
194
|
+
authentication.user.is_authenticated
|
140
195
|
and authentication.credentials.token.type == BaseEnums.TokenType.REFRESH
|
141
|
-
and
|
142
|
-
and "logout" not in
|
143
|
-
)
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
parameters = (
|
151
|
-
MaleoFoundationTokenParametersTransfers
|
152
|
-
.Encode(key=self._keys.private, password=self._keys.password, payload=payload)
|
153
|
-
)
|
154
|
-
result = self._maleo_foundation.services.token.encode(parameters=parameters)
|
155
|
-
if result.success:
|
156
|
-
response.headers["X-New-Authorization"] = result.data.token
|
157
|
-
return response
|
196
|
+
and 200 <= response.status_code < 300
|
197
|
+
and "logout" not in request_context.url
|
198
|
+
)
|
199
|
+
|
200
|
+
class RequestLogger:
|
201
|
+
"""Handles request/response logging."""
|
202
|
+
|
203
|
+
def __init__(self, logger:MiddlewareLogger):
|
204
|
+
self.logger = logger
|
158
205
|
|
159
|
-
def
|
206
|
+
def log_request_response(
|
160
207
|
self,
|
161
|
-
request:Request,
|
162
208
|
authentication:Authentication,
|
163
|
-
authentication_info:str,
|
164
209
|
response:Response,
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
request,
|
173
|
-
authentication,
|
174
|
-
response,
|
175
|
-
request_timestamp,
|
176
|
-
response_timestamp,
|
177
|
-
process_time
|
178
|
-
)
|
179
|
-
log_func = getattr(self._logger, log_level)
|
210
|
+
request_context:RequestContextTransfers,
|
211
|
+
log_level:str = "info"
|
212
|
+
) -> None:
|
213
|
+
"""Log request and response details."""
|
214
|
+
authentication_info = self._get_authentication_info(authentication)
|
215
|
+
|
216
|
+
log_func = getattr(self.logger, log_level)
|
180
217
|
log_func(
|
181
|
-
f"Request
|
182
|
-
f"
|
218
|
+
f"Request | ID: {request_context.request_id} {authentication_info} | "
|
219
|
+
f"IP: {request_context.ip_address} | Host: {request_context.host} | "
|
220
|
+
f"Method: {request_context.method} | URL: {request_context.url} - "
|
221
|
+
f"Response | Status: {response.status_code}"
|
183
222
|
)
|
184
|
-
|
185
|
-
|
186
|
-
def _handle_exception(
|
223
|
+
|
224
|
+
def log_exception(
|
187
225
|
self,
|
188
|
-
request:Request,
|
189
226
|
authentication:Authentication,
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
):
|
197
|
-
traceback_str = traceback.format_exc().split("\n")
|
227
|
+
error:Exception,
|
228
|
+
request_context:RequestContextTransfers
|
229
|
+
) -> None:
|
230
|
+
"""Log exception details."""
|
231
|
+
authentication_info = self._get_authentication_info(authentication)
|
232
|
+
|
198
233
|
error_details = {
|
234
|
+
"request_context": request_context.model_dump(mode="json"),
|
199
235
|
"error": str(error),
|
200
|
-
"traceback":
|
201
|
-
"client_ip": client_ip,
|
202
|
-
"method": request.method,
|
203
|
-
"url": request.url.path,
|
204
|
-
"headers": dict(request.headers),
|
236
|
+
"traceback": traceback.format_exc().split("\n")
|
205
237
|
}
|
206
238
|
|
207
|
-
|
208
|
-
|
209
|
-
|
239
|
+
self.logger.error(
|
240
|
+
f"Request | ID: {request_context.request_id} {authentication_info} | "
|
241
|
+
f"IP: {request_context.ip_address} | Host: {request_context.host} | "
|
242
|
+
f"Method: {request_context.method} | URL: {request_context.url} - "
|
243
|
+
f"Response | Status: 500 | Exception:\n{json.dumps(error_details, indent=4)}"
|
210
244
|
)
|
211
245
|
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
246
|
+
def _get_authentication_info(self, authentication:Authentication) -> str:
|
247
|
+
"""Get authentication info string."""
|
248
|
+
if not authentication.user.is_authenticated:
|
249
|
+
return "| Unauthenticated"
|
216
250
|
|
217
|
-
return
|
218
|
-
|
219
|
-
authentication
|
220
|
-
|
221
|
-
request_timestamp,
|
222
|
-
response_timestamp,
|
223
|
-
process_time
|
251
|
+
return (
|
252
|
+
f"| Token type: {authentication.credentials.token.type} | "
|
253
|
+
f"Username: {authentication.user.display_name} | "
|
254
|
+
f"Email: {authentication.user.identity}"
|
224
255
|
)
|
225
256
|
|
226
|
-
async def _request_processor(self, request:Request) -> Optional[Response]:
|
227
|
-
return None
|
228
257
|
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
258
|
+
class BaseMiddleware(BaseHTTPMiddleware):
|
259
|
+
"""Base middleware with rate limiting, logging, and response enhancement."""
|
260
|
+
|
261
|
+
def __init__(
|
262
|
+
self,
|
263
|
+
app:FastAPI,
|
264
|
+
keys:BaseGeneralSchemas.RSAKeys,
|
265
|
+
logger:MiddlewareLogger,
|
266
|
+
maleo_foundation:MaleoFoundationClientManager,
|
267
|
+
allow_origins:Sequence[str] = (),
|
268
|
+
allow_methods:Sequence[str] = ("GET",),
|
269
|
+
allow_headers:Sequence[str] = (),
|
270
|
+
allow_credentials:bool = False,
|
271
|
+
limit:int = 10,
|
272
|
+
window:int = 1,
|
273
|
+
cleanup_interval:int = 60,
|
274
|
+
ip_timeout:int = 300
|
275
|
+
):
|
276
|
+
super().__init__(app)
|
277
|
+
|
278
|
+
# Core components
|
279
|
+
self.rate_limiter = RateLimiter(
|
280
|
+
limit=limit,
|
281
|
+
window=timedelta(seconds=window),
|
282
|
+
ip_timeout=timedelta(seconds=ip_timeout),
|
283
|
+
cleanup_interval=timedelta(seconds=cleanup_interval)
|
237
284
|
)
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
285
|
+
self.response_builder = ResponseBuilder(keys, maleo_foundation)
|
286
|
+
self.request_logger = RequestLogger(logger)
|
287
|
+
|
288
|
+
# CORS settings (if needed)
|
289
|
+
self.cors_config = {
|
290
|
+
'allow_origins': allow_origins,
|
291
|
+
'allow_methods': allow_methods,
|
292
|
+
'allow_headers': allow_headers,
|
293
|
+
'allow_credentials': allow_credentials,
|
294
|
+
}
|
295
|
+
|
296
|
+
async def dispatch(self, request:Request, call_next:RequestResponseEndpoint) -> Response:
|
297
|
+
"""Main middleware dispatch method."""
|
298
|
+
# Setup
|
299
|
+
self.rate_limiter.cleanup_old_data(self.request_logger.logger)
|
300
|
+
request_context = extract_request_context(request)
|
301
|
+
request.state.request_context = request_context
|
302
|
+
start_time = time.perf_counter()
|
303
|
+
authentication = Authentication(credentials=request.auth, user=request.user)
|
242
304
|
|
243
305
|
try:
|
244
|
-
|
245
|
-
if self.
|
246
|
-
return self.
|
247
|
-
|
248
|
-
authentication=authentication,
|
249
|
-
authentication_info=authentication_info,
|
250
|
-
response=JSONResponse(
|
251
|
-
content=BaseResponses.RateLimitExceeded().model_dump(),
|
252
|
-
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
253
|
-
),
|
254
|
-
request_timestamp=request_timestamp,
|
255
|
-
response_timestamp=datetime.now(tz=timezone.utc),
|
256
|
-
process_time=time.perf_counter() - start_time,
|
257
|
-
log_level="warning",
|
258
|
-
client_ip=client_ip,
|
306
|
+
# Rate limiting check
|
307
|
+
if self.rate_limiter.is_rate_limited(request_context):
|
308
|
+
return self._create_rate_limit_response(
|
309
|
+
authentication, request_context, start_time
|
259
310
|
)
|
260
311
|
|
261
|
-
|
312
|
+
# Optional preprocessing
|
262
313
|
pre_response = await self._request_processor(request)
|
263
314
|
if pre_response is not None:
|
264
|
-
return self.
|
265
|
-
|
266
|
-
authentication=authentication,
|
267
|
-
authentication_info=authentication_info,
|
268
|
-
response=pre_response,
|
269
|
-
request_timestamp=request_timestamp,
|
270
|
-
response_timestamp=datetime.now(tz=timezone.utc),
|
271
|
-
process_time=time.perf_counter() - start_time,
|
272
|
-
log_level="info",
|
273
|
-
client_ip=client_ip,
|
315
|
+
return self._build_final_response(
|
316
|
+
authentication, pre_response, request_context, start_time
|
274
317
|
)
|
275
318
|
|
276
|
-
|
319
|
+
# Main request processing
|
277
320
|
response = await call_next(request)
|
278
|
-
|
279
|
-
|
280
|
-
authentication=authentication,
|
281
|
-
authentication_info=authentication_info,
|
282
|
-
response=response,
|
283
|
-
request_timestamp=request_timestamp,
|
284
|
-
response_timestamp=datetime.now(tz=timezone.utc),
|
285
|
-
process_time=time.perf_counter() - start_time,
|
286
|
-
log_level="info",
|
287
|
-
client_ip=client_ip,
|
321
|
+
return self._build_final_response(
|
322
|
+
authentication, response, request_context, start_time
|
288
323
|
)
|
289
324
|
|
290
|
-
return response
|
291
|
-
|
292
325
|
except Exception as e:
|
293
326
|
return self._handle_exception(
|
294
|
-
request
|
295
|
-
authentication=authentication,
|
296
|
-
authentication_info=authentication_info,
|
297
|
-
error=e,
|
298
|
-
request_timestamp=request_timestamp,
|
299
|
-
response_timestamp=datetime.now(tz=timezone.utc),
|
300
|
-
process_time=time.perf_counter() - start_time,
|
301
|
-
client_ip=client_ip
|
327
|
+
request, authentication, e, request_context, start_time
|
302
328
|
)
|
303
329
|
|
330
|
+
async def _request_processor(self, request:Request) -> Optional[Response]:
|
331
|
+
"""Override this method for custom request preprocessing."""
|
332
|
+
return None
|
333
|
+
|
334
|
+
def _create_rate_limit_response(
|
335
|
+
self,
|
336
|
+
authentication:Authentication,
|
337
|
+
request_context:RequestContextTransfers,
|
338
|
+
start_time:float
|
339
|
+
) -> Response:
|
340
|
+
"""Create rate limit exceeded response."""
|
341
|
+
response = JSONResponse(
|
342
|
+
content=BaseResponses.RateLimitExceeded().model_dump(),
|
343
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
344
|
+
)
|
345
|
+
|
346
|
+
return self._build_final_response(
|
347
|
+
authentication, response, request_context, start_time, log_level="warning"
|
348
|
+
)
|
349
|
+
|
350
|
+
def _build_final_response(
|
351
|
+
self,
|
352
|
+
authentication:Authentication,
|
353
|
+
response:Response,
|
354
|
+
request_context:RequestContextTransfers,
|
355
|
+
start_time:float,
|
356
|
+
log_level:str = "info"
|
357
|
+
) -> Response:
|
358
|
+
"""Build final response with headers and logging."""
|
359
|
+
responded_at = datetime.now(tz=timezone.utc)
|
360
|
+
process_time = time.perf_counter() - start_time
|
361
|
+
|
362
|
+
# Add headers
|
363
|
+
response = self.response_builder.add_response_headers(
|
364
|
+
authentication, response, request_context, responded_at, process_time
|
365
|
+
)
|
366
|
+
|
367
|
+
# Log request/response
|
368
|
+
self.request_logger.log_request_response(
|
369
|
+
authentication, response, request_context, log_level
|
370
|
+
)
|
371
|
+
|
372
|
+
return response
|
373
|
+
|
374
|
+
def _handle_exception(
|
375
|
+
self,
|
376
|
+
authentication:Authentication,
|
377
|
+
error:Exception,
|
378
|
+
request_context:RequestContextTransfers,
|
379
|
+
start_time:float
|
380
|
+
) -> Response:
|
381
|
+
"""Handle exceptions and create error response."""
|
382
|
+
responded_at = datetime.now(tz=timezone.utc)
|
383
|
+
process_time = time.perf_counter() - start_time
|
384
|
+
|
385
|
+
response = JSONResponse(
|
386
|
+
content=BaseResponses.ServerError().model_dump(),
|
387
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
388
|
+
)
|
389
|
+
|
390
|
+
# Log exception
|
391
|
+
self.request_logger.log_exception(authentication, error, request_context)
|
392
|
+
|
393
|
+
# Add headers
|
394
|
+
return self.response_builder.add_response_headers(
|
395
|
+
authentication, response, request_context, responded_at, process_time
|
396
|
+
)
|
397
|
+
|
398
|
+
|
304
399
|
def add_base_middleware(
|
305
400
|
app:FastAPI,
|
306
401
|
keys:BaseGeneralSchemas.RSAKeys,
|
@@ -316,39 +411,35 @@ def add_base_middleware(
|
|
316
411
|
ip_timeout:int = 300
|
317
412
|
) -> None:
|
318
413
|
"""
|
319
|
-
|
414
|
+
Add Base middleware to the FastAPI application.
|
320
415
|
|
321
416
|
Args:
|
322
|
-
app:
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
window:
|
332
|
-
|
333
|
-
|
334
|
-
cleanup_interval: int
|
335
|
-
How often to clean up old IP data (in seconds).
|
336
|
-
|
337
|
-
ip_timeout: int
|
338
|
-
How long to keep an IP in memory after its last activity (in seconds).
|
339
|
-
Default is 300 seconds (5 minutes).
|
340
|
-
|
341
|
-
Returns:
|
342
|
-
None: The function modifies the FastAPI app by adding Base middleware.
|
343
|
-
|
344
|
-
Note:
|
345
|
-
FastAPI applies middleware in reverse order of registration, so this middleware
|
346
|
-
will execute after any middleware added subsequently.
|
417
|
+
app:FastAPI application instance
|
418
|
+
keys:RSA keys for signing and token generation
|
419
|
+
logger:Middleware logger instance
|
420
|
+
maleo_foundation:Client manager for foundation services
|
421
|
+
allow_origins:CORS allowed origins
|
422
|
+
allow_methods:CORS allowed methods
|
423
|
+
allow_headers:CORS allowed headers
|
424
|
+
allow_credentials:CORS allow credentials flag
|
425
|
+
limit:Request count limit per window
|
426
|
+
window:Time window for rate limiting (seconds)
|
427
|
+
cleanup_interval:Cleanup interval for old IP data (seconds)
|
428
|
+
ip_timeout:IP timeout after last activity (seconds)
|
347
429
|
|
348
430
|
Example:
|
349
|
-
|
350
|
-
|
351
|
-
|
431
|
+
```python
|
432
|
+
add_base_middleware(
|
433
|
+
app=app,
|
434
|
+
keys=rsa_keys,
|
435
|
+
logger=middleware_logger,
|
436
|
+
maleo_foundation=client_manager,
|
437
|
+
limit=10,
|
438
|
+
window=1,
|
439
|
+
cleanup_interval=60,
|
440
|
+
ip_timeout=300
|
441
|
+
)
|
442
|
+
```
|
352
443
|
"""
|
353
444
|
app.add_middleware(
|
354
445
|
BaseMiddleware,
|
@@ -1,5 +1,10 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
+
from datetime import datetime, timezone
|
3
|
+
from pydantic import BaseModel, Field
|
4
|
+
from typing import Dict
|
5
|
+
from uuid import UUID
|
2
6
|
from maleo_foundation.models.schemas.general import BaseGeneralSchemas
|
7
|
+
from maleo_foundation.types import BaseTypes
|
3
8
|
from .token import MaleoFoundationTokenGeneralTransfers
|
4
9
|
|
5
10
|
class BaseGeneralTransfers:
|
@@ -8,4 +13,23 @@ class BaseGeneralTransfers:
|
|
8
13
|
class AccessTransfers(
|
9
14
|
BaseGeneralSchemas.AccessedBy,
|
10
15
|
BaseGeneralSchemas.AccessedAt
|
11
|
-
): pass
|
16
|
+
): pass
|
17
|
+
|
18
|
+
class RequestContextTransfers(BaseModel):
|
19
|
+
request_id:UUID = Field(..., description="Unique identifier for tracing the request")
|
20
|
+
requested_at:datetime = Field(datetime.now(tz=timezone.utc), description="Request timestamp")
|
21
|
+
method:str = Field(..., description="Request's method")
|
22
|
+
url:str = Field(..., description="Request's URL")
|
23
|
+
path_params:Dict = Field(..., description="Request's path parameters")
|
24
|
+
query_params:Dict = Field(..., description="Request's query parameters")
|
25
|
+
ip_address:str = Field("unknown", description="Client's IP address")
|
26
|
+
is_internal:BaseTypes.OptionalBoolean = Field(None, description="True if IP is internal")
|
27
|
+
user_agent:BaseTypes.OptionalString = Field(None, description="User-Agent string")
|
28
|
+
ua_browser:BaseTypes.OptionalString = Field(None, description="Browser info from sec-ch-ua")
|
29
|
+
ua_mobile:BaseTypes.OptionalString = Field(None, description="Is mobile device?")
|
30
|
+
platform:BaseTypes.OptionalString = Field(None, description="Client platform or OS")
|
31
|
+
referer:BaseTypes.OptionalString = Field(None, description="Referrer URL")
|
32
|
+
origin:BaseTypes.OptionalString = Field(None, description="Origin of the request")
|
33
|
+
host:BaseTypes.OptionalString = Field(None, description="Host header from request")
|
34
|
+
forwarded_proto:BaseTypes.OptionalString = Field(None, description="Forwarded protocol (http/https)")
|
35
|
+
language:BaseTypes.OptionalString = Field(None, description="Accepted languages from client")
|
@@ -0,0 +1,9 @@
|
|
1
|
+
from fastapi.requests import Request
|
2
|
+
from maleo_foundation.models.transfers.general import RequestContextTransfers
|
3
|
+
|
4
|
+
class ContextDependencies:
|
5
|
+
@staticmethod
|
6
|
+
def get_request_context(
|
7
|
+
request:Request
|
8
|
+
) -> RequestContextTransfers:
|
9
|
+
return request.state.request_context
|
@@ -1,20 +1,55 @@
|
|
1
|
+
from datetime import datetime, timezone
|
2
|
+
from fastapi import Request
|
1
3
|
from starlette.requests import HTTPConnection
|
4
|
+
from uuid import uuid4
|
5
|
+
from maleo_foundation.models.transfers.general import RequestContextTransfers
|
2
6
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
x_real_ip
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
7
|
+
def extract_client_ip(conn:HTTPConnection) -> str:
|
8
|
+
"""Extract client IP with more robust handling of proxies"""
|
9
|
+
#* Check for X-Forwarded-For header (common when behind proxy/load balancer)
|
10
|
+
x_forwarded_for = conn.headers.get("X-Forwarded-For")
|
11
|
+
if x_forwarded_for:
|
12
|
+
#* The client's IP is the first one in the list
|
13
|
+
ips = [ip.strip() for ip in x_forwarded_for.split(",")]
|
14
|
+
return ips[0]
|
15
|
+
|
16
|
+
#* Check for X-Real-IP header (used by some proxies)
|
17
|
+
x_real_ip = conn.headers.get("X-Real-IP")
|
18
|
+
if x_real_ip:
|
19
|
+
return x_real_ip
|
20
|
+
|
21
|
+
#* Fall back to direct client connection
|
22
|
+
return conn.client.host if conn.client else "unknown"
|
23
|
+
|
24
|
+
def extract_request_context(request:Request) -> RequestContextTransfers:
|
25
|
+
headers = request.headers
|
26
|
+
|
27
|
+
request_id = headers.get("x-request-id")
|
28
|
+
if request_id is None:
|
29
|
+
request_id = uuid4()
|
30
|
+
|
31
|
+
ip_address = extract_client_ip(request)
|
32
|
+
|
33
|
+
ua_browser = headers.get("sec-ch-ua", "")
|
34
|
+
if ua_browser:
|
35
|
+
ua_browser = ua_browser.replace('"', "").split(",")[0].strip()
|
36
|
+
|
37
|
+
return RequestContextTransfers(
|
38
|
+
request_id=request_id,
|
39
|
+
requested_at=datetime.now(tz=timezone.utc),
|
40
|
+
method=request.method,
|
41
|
+
url=request.url.path,
|
42
|
+
path_params=dict(request.path_params),
|
43
|
+
query_params=dict(request.query_params),
|
44
|
+
ip_address=ip_address,
|
45
|
+
is_internal=ip_address.startswith("10.") or ip_address.startswith("192.168.") or ip_address.startswith("172."),
|
46
|
+
user_agent=headers.get("user-agent"),
|
47
|
+
ua_browser=ua_browser,
|
48
|
+
ua_mobile=headers.get("sec-ch-ua-mobile"),
|
49
|
+
platform=headers.get("sec-ch-ua-platform"),
|
50
|
+
referer=headers.get("referer"),
|
51
|
+
origin=headers.get("origin"),
|
52
|
+
host=headers.get("host"),
|
53
|
+
forwarded_proto=headers.get("x-forwarded-proto"),
|
54
|
+
language=headers.get("accept-language"),
|
55
|
+
)
|
@@ -34,7 +34,7 @@ maleo_foundation/expanded_types/encryption/rsa.py,sha256=Esf_H8nMz2kOLAWa3M7dlD-
|
|
34
34
|
maleo_foundation/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
35
35
|
maleo_foundation/managers/db.py,sha256=cpY1IOiUytT9XXYtzS0E9OSYOuB7jBKo0XHe__uI1Jg,5340
|
36
36
|
maleo_foundation/managers/middleware.py,sha256=77wRCC_CWc22nSDL-UJanO3rXmSB7wLzaAIXEFjXq6M,4249
|
37
|
-
maleo_foundation/managers/service.py,sha256=
|
37
|
+
maleo_foundation/managers/service.py,sha256=9qPmOUh5PPXu03-xnZIRU4C3mNbMOnYrfJQs87oqt6k,19282
|
38
38
|
maleo_foundation/managers/cache/__init__.py,sha256=CeY0oof2bVl_v5WS-FKXNwn2gf3xrEMfUsHK9cHo59s,471
|
39
39
|
maleo_foundation/managers/cache/base.py,sha256=YyPjde4KTsp2IHV6NdFMysa0ev-1GX1rtX-0jQPuIBU,837
|
40
40
|
maleo_foundation/managers/cache/redis.py,sha256=xLa8QfXdNtghs0eBxIqc04H3XTYmxLEzrqJZAFCigvM,1150
|
@@ -47,7 +47,7 @@ maleo_foundation/managers/client/google/parameter.py,sha256=Lnj7mQgxWQpsQwbmDRK5
|
|
47
47
|
maleo_foundation/managers/client/google/secret.py,sha256=Ski1CHYeA8vjSk2Oc2Pf4CfFrzT_RcA6NEZwza7gM7Y,4464
|
48
48
|
maleo_foundation/managers/client/google/storage.py,sha256=Hq79cVreWR2AGwUzpD57NzziZjqxj__TqYIBRy-A9A0,5340
|
49
49
|
maleo_foundation/middlewares/authentication.py,sha256=UL6kL65SvqrzemlIDopoO9N1C05eWlYMHVR2tiRsVEA,4821
|
50
|
-
maleo_foundation/middlewares/base.py,sha256=
|
50
|
+
maleo_foundation/middlewares/base.py,sha256=r3XDTEhK6BB8YZ1Y8oyqExY78s4yzOqX7s7XokRlTlw,16308
|
51
51
|
maleo_foundation/middlewares/cors.py,sha256=9uvBvY2N6Vxa9RP_YtESxcWo6Doi6uS0lzAG9iLY7Uc,2288
|
52
52
|
maleo_foundation/models/__init__.py,sha256=AaKehO7c1HyKhoTGRmNHDddSeBXkW-_YNrpOGBu8Ms8,246
|
53
53
|
maleo_foundation/models/responses.py,sha256=nE5qThK-WgcYB-9J4wHzJltMA3PLmWbMI-dkxAxAdII,5631
|
@@ -62,7 +62,7 @@ maleo_foundation/models/schemas/result.py,sha256=MaWDyKGvZu0IcvKMj30lrMXojGV9Mhi
|
|
62
62
|
maleo_foundation/models/schemas/signature.py,sha256=-5ldTnJsjwqPRbHw7PFcLKITqEXJ_qKDdRHShK75NVA,620
|
63
63
|
maleo_foundation/models/schemas/token.py,sha256=RYq8v1T_WZIu8lcjwyV6Pp7ZjFkr_lW9x6QyDmXBsfw,563
|
64
64
|
maleo_foundation/models/transfers/__init__.py,sha256=oJLJ3Geeme6vBw7R2Dhvdvg4ziVvzEYAGJaP-tm_90w,299
|
65
|
-
maleo_foundation/models/transfers/general/__init__.py,sha256=
|
65
|
+
maleo_foundation/models/transfers/general/__init__.py,sha256=IOt9afCpDu7bO0rkZLiiLLP37w5rIZTxmnv6MD9od8E,1990
|
66
66
|
maleo_foundation/models/transfers/general/key.py,sha256=tLKkXbwNu7Oc1MdKa8-Y7TlBAIk54h_02AmL5Yg2PrQ,751
|
67
67
|
maleo_foundation/models/transfers/general/signature.py,sha256=J9xQy2HjpCQOnES7RJqsUnDgjFPuakQ1mxyfdTdstSE,297
|
68
68
|
maleo_foundation/models/transfers/general/token.py,sha256=O_U6dQS7oMScJzqufl6Pe21pTxMsYhOzKH8aFLxjblQ,2895
|
@@ -101,7 +101,7 @@ maleo_foundation/utils/__init__.py,sha256=KoERe8U2ERGZeAKUNBPW_itk7g9YpH7v7_mD9_
|
|
101
101
|
maleo_foundation/utils/client.py,sha256=F5X9TUxWQgeOHjwsMpPoSRhZANQYZ_iFv0RJDTUVhrw,2820
|
102
102
|
maleo_foundation/utils/controller.py,sha256=D8uUfqkMpPw8M8Ykn8Uxn7dfRGwxmBxSP1h9xSkYw1I,6945
|
103
103
|
maleo_foundation/utils/exceptions.py,sha256=eM__Mxo-BC5d7JmoBd-Wh-fmryUdfjNfOuY_eONK5Fk,6361
|
104
|
-
maleo_foundation/utils/extractor.py,sha256=
|
104
|
+
maleo_foundation/utils/extractor.py,sha256=0HoJ0hLqt3-izaekjPfxqWjvUmXTsm4Muyttu0_VyuI,2109
|
105
105
|
maleo_foundation/utils/logging.py,sha256=W5Fhk_xAXVqSujaY8mv3hRH4wlQSpUn4ReuMoiKcQa4,7759
|
106
106
|
maleo_foundation/utils/merger.py,sha256=z9GROLVtGpwx84bOiakBFphKazsI-9l3F3WauTDwQLs,597
|
107
107
|
maleo_foundation/utils/query.py,sha256=nDj-9Q-5eAOvqXqKgKjtHaFAxVAY87E0BMaVXmTN7jA,7957
|
@@ -109,6 +109,7 @@ maleo_foundation/utils/repository.py,sha256=knBi3xOLlhBIEtChvqbZh4wXmgrFCB3rDwQX
|
|
109
109
|
maleo_foundation/utils/searcher.py,sha256=gsseMkr0swKj3l-xil5qqncTbYNkS96eieFE_ehaF8I,504
|
110
110
|
maleo_foundation/utils/dependencies/__init__.py,sha256=0KKGrdfj8Cc5A4SRk_ZBAxzOP795Mizdb4zIBh07KC4,122
|
111
111
|
maleo_foundation/utils/dependencies/auth.py,sha256=wS9qnmd1n2WsFhiSfyq_3Io3wwZKTENVPv4rMwxJslE,727
|
112
|
+
maleo_foundation/utils/dependencies/context.py,sha256=OXem2E00u2D3GleXhV8VI3u0cayG2CZpvVz32a0xHhs,292
|
112
113
|
maleo_foundation/utils/formatter/__init__.py,sha256=iKf5YCbEdg1qKnFHyKqqcQbqAqEeRUf8mhI3v3dQoj8,78
|
113
114
|
maleo_foundation/utils/formatter/case.py,sha256=TmvvlfzGdC_omMTB5vAa40TZBxQ3hnr-SYeo0M52Rlg,1352
|
114
115
|
maleo_foundation/utils/loaders/__init__.py,sha256=P_3ycGfeDXFjAi8bE4iLWHxBveqUIdpHgGv-klRWM3s,282
|
@@ -118,7 +119,7 @@ maleo_foundation/utils/loaders/credential/__init__.py,sha256=qopTKvcMVoTFwyRijeg
|
|
118
119
|
maleo_foundation/utils/loaders/credential/google.py,sha256=HUcuHD4tXHPt0eHInlFYxA_MDrGSOtbenpd0PX156OM,1255
|
119
120
|
maleo_foundation/utils/loaders/key/__init__.py,sha256=hVygcC2ImHc_aVrSrOmyedR8tMUZokWUKCKOSh5ctbo,106
|
120
121
|
maleo_foundation/utils/loaders/key/rsa.py,sha256=gDhyX6iTFtHiluuhFCozaZ3pOLKU2Y9TlrNMK_GVyGU,3796
|
121
|
-
maleo_foundation-0.2.
|
122
|
-
maleo_foundation-0.2.
|
123
|
-
maleo_foundation-0.2.
|
124
|
-
maleo_foundation-0.2.
|
122
|
+
maleo_foundation-0.2.81.dist-info/METADATA,sha256=JLiCodCLzxMikL7jL0H8wQRjpRvlolRdVr-_iSfz0xM,3598
|
123
|
+
maleo_foundation-0.2.81.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
124
|
+
maleo_foundation-0.2.81.dist-info/top_level.txt,sha256=_iBos3F_bhEOdjOnzeiEYSrCucasc810xXtLBXI8cQc,17
|
125
|
+
maleo_foundation-0.2.81.dist-info/RECORD,,
|
File without changes
|
File without changes
|