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.
@@ -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 addedd successfully")
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 addedd successfully")
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
- from typing import Awaitable, Callable, Optional, Sequence
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
- import MaleoFoundationTokenGeneralTransfers
18
- from maleo_foundation.models.transfers.parameters.token \
19
- import MaleoFoundationTokenParametersTransfers
20
- from maleo_foundation.models.transfers.parameters.signature \
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 BaseMiddleware(BaseHTTPMiddleware):
28
+ class RateLimiter:
29
+ """Thread-safe rate limiter with automatic cleanup."""
30
+
29
31
  def __init__(
30
32
  self,
31
- app:FastAPI,
32
- keys:BaseGeneralSchemas.RSAKeys,
33
- logger:MiddlewareLogger,
34
- maleo_foundation:MaleoFoundationClientManager,
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
- super().__init__(app)
45
- self._keys = keys
46
- self._logger = logger
47
- self._maleo_foundation = maleo_foundation
48
- self._allow_origins = allow_origins
49
- self._allow_methods = allow_methods
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() #* Use RLock for thread safety
60
-
61
- def _cleanup_old_data(self) -> None:
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
- client_ip:str
49
+ request_context:RequestContextTransfers
98
50
  ) -> bool:
99
- """Check if the client has exceeded their rate limit"""
51
+ """Check if client IP is rate limited and record the request."""
100
52
  with self._lock:
101
- now = datetime.now() #* Define current timestamp
102
- self._last_seen[client_ip] = now #* Update last seen timestamp for this IP
53
+ now = datetime.now()
54
+ client_ip = request_context.ip_address
55
+ self._last_seen[client_ip] = now
103
56
 
104
- #* Filter requests within the window
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._window
60
+ if now - timestamp <= self.window
108
61
  ]
109
62
 
110
- #* Check if the request count exceeds the limit
111
- if len(self._requests[client_ip]) >= self._limit:
63
+ # Check rate limit
64
+ if len(self._requests[client_ip]) >= self.limit:
112
65
  return True
113
66
 
114
- #* Add the current request timestamp
67
+ # Record this request
115
68
  self._requests[client_ip].append(now)
116
69
  return False
117
70
 
118
- def _add_response_headers(
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
- request_timestamp:datetime,
124
- response_timestamp:datetime,
125
- process_time:int
120
+ request_context:RequestContextTransfers,
121
+ responded_at:datetime,
122
+ process_time:float
126
123
  ) -> Response:
127
- response.headers["X-Process-Time"] = str(process_time) #* Add Process Time Header
128
- response.headers["X-Request-Timestamp"] = request_timestamp.isoformat() #* Add request timestamp header
129
- response.headers["X-Response-Timestamp"] = response_timestamp.isoformat() #* Define and add response timestamp header
130
- #* Generate signature header
131
- message = f"{request.method}|{request.url.path}|{request_timestamp.isoformat()}|{response_timestamp.isoformat()}|{str(process_time)}"
132
- sign_parameters = (
133
- MaleoFoundationSignatureParametersTransfers
134
- .Sign(key=self._keys.private, password=self._keys.password, message=message)
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
- sign_result = self._maleo_foundation.services.signature.sign(parameters=sign_parameters)
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
- if (authentication.user.is_authenticated
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 (response.status_code >= 200 and response.status_code < 300)
142
- and "logout" not in request.url.path
143
- ):
144
- #* Regenerate new authorization
145
- payload = (
146
- MaleoFoundationTokenGeneralTransfers
147
- .BaseEncodePayload
148
- .model_validate(authentication.credentials.token.payload.model_dump())
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 _build_response(
206
+ def log_request_response(
160
207
  self,
161
- request:Request,
162
208
  authentication:Authentication,
163
- authentication_info:str,
164
209
  response:Response,
165
- request_timestamp:datetime,
166
- response_timestamp:datetime,
167
- process_time:int,
168
- log_level:str = "info",
169
- client_ip:str = "unknown"
170
- ) -> Response:
171
- response = self._add_response_headers(
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 {authentication_info} | IP: {client_ip} | Host: {request.client.host} | Port: {request.client.port} | Method: {request.method} | URL: {request.url.path} | "
182
- f"Headers: {dict(request.headers)} - Response | Status: {response.status_code}"
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
- return response
185
-
186
- def _handle_exception(
223
+
224
+ def log_exception(
187
225
  self,
188
- request:Request,
189
226
  authentication:Authentication,
190
- authentication_info:str,
191
- error,
192
- request_timestamp:datetime,
193
- response_timestamp:datetime,
194
- process_time:int,
195
- client_ip:str = "unknown"
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": traceback_str,
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
- response = JSONResponse(
208
- content=BaseResponses.ServerError().model_dump(),
209
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
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
- self._logger.error(
213
- f"Request {authentication_info} | IP: {client_ip} | Host: {request.client.host} | Port: {request.client.port} | Method: {request.method} | URL: {request.url.path} | "
214
- f"Headers: {dict(request.headers)} - Response | Status: 500 | Exception:\n{json.dumps(error_details, indent=4)}"
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 self._add_response_headers(
218
- request,
219
- authentication,
220
- response,
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
- async def dispatch(self, request:Request, call_next:RequestResponseEndpoint):
230
- self._cleanup_old_data() #* Run periodic cleanup
231
- request_timestamp = datetime.now(tz=timezone.utc) #* Record the request timestamp
232
- start_time = time.perf_counter() #* Record the start time
233
- client_ip = BaseExtractors.extract_client_ip(request) #* Get request IP with improved extraction
234
- authentication = Authentication(
235
- credentials=request.auth,
236
- user=request.user
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
- if not authentication.user.is_authenticated:
239
- authentication_info = "| Unauthenticated"
240
- else:
241
- authentication_info = f"| Token type: {authentication.credentials.token.type} | Username: {authentication.user.display_name} | Email:{authentication.user.identity}"
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
- #* 1. Rate limit check
245
- if self._check_rate_limit(client_ip):
246
- return self._build_response(
247
- request=request,
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
- #* 2. Optional preprocessing
312
+ # Optional preprocessing
262
313
  pre_response = await self._request_processor(request)
263
314
  if pre_response is not None:
264
- return self._build_response(
265
- request=request,
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
- #* 3. Main handler
319
+ # Main request processing
277
320
  response = await call_next(request)
278
- response = self._build_response(
279
- request=request,
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=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
- Adds Base middleware to the FastAPI application.
414
+ Add Base middleware to the FastAPI application.
320
415
 
321
416
  Args:
322
- app: FastAPI
323
- The FastAPI application instance to which the middleware will be added.
324
-
325
- logger: Logger
326
- The middleware logger to be used.
327
-
328
- limit: int
329
- Request count limit in a specific window of time
330
-
331
- window: int
332
- Time window for rate limiting (in seconds).
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
- ```python
350
- add_base_middleware(app=app, limit=10, window=1, cleanup_interval=60, ip_timeout=300)
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
- class BaseExtractors:
4
- @staticmethod
5
- def extract_client_ip(conn:HTTPConnection) -> str:
6
- """Extract client IP with more robust handling of proxies"""
7
- #* Check for X-Forwarded-For header (common when behind proxy/load balancer)
8
- x_forwarded_for = conn.headers.get("X-Forwarded-For")
9
- if x_forwarded_for:
10
- #* The client's IP is the first one in the list
11
- ips = [ip.strip() for ip in x_forwarded_for.split(",")]
12
- return ips[0]
13
-
14
- #* Check for X-Real-IP header (used by some proxies)
15
- x_real_ip = conn.headers.get("X-Real-IP")
16
- if x_real_ip:
17
- return x_real_ip
18
-
19
- #* Fall back to direct client connection
20
- return conn.client.host if conn.client else "unknown"
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
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maleo_foundation
3
- Version: 0.2.78
3
+ Version: 0.2.81
4
4
  Summary: Foundation package for Maleo
5
5
  Author-email: Agra Bima Yuda <agra@nexmedis.com>
6
6
  License: MIT
@@ -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=_0eme0NQMsf_8UlLmrLkD9OzHrLbrKjMYUNGYcA0EPI,19179
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=fLctfwzfWlGSPFnSiqbV-9wDhlG2SLA1DAs-hXk0diU,14600
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=7yQNGSihJt1db6i_zIrhLdY2Tgn5lrTxuDlAVlDh2vo,340
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=SZXVYDHWGaA-Dd1BUydwF2HHdZqexEielS4CjL0Ceng,814
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.78.dist-info/METADATA,sha256=ClqJ8m73PDVT6xirjl0uPeyFm_r93Lk9Lkm1DyfoLOY,3598
122
- maleo_foundation-0.2.78.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
123
- maleo_foundation-0.2.78.dist-info/top_level.txt,sha256=_iBos3F_bhEOdjOnzeiEYSrCucasc810xXtLBXI8cQc,17
124
- maleo_foundation-0.2.78.dist-info/RECORD,,
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,,