Restructure with Turborepo
Run Tests on Branches / Backend Tests (push) Successful in 3m38s
Run Tests on Branches / Frontend Tests (push) Successful in 3m28s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-06 15:51:53 +02:00
parent f66cafbbc3
commit 190b3d163d
277 changed files with 53253 additions and 0 deletions
@@ -0,0 +1,91 @@
from __future__ import annotations
import time
import uuid
from typing import Any
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from src.core.logging import get_logger
from src.domain.entities import AuditLogEntity
from src.domain.interfaces import IAuditLogRepository
from src.infrastructure.database.session import async_session_factory
logger = get_logger("audit_middleware")
WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
IGNORED_PATHS = {"/docs", "/redoc", "/openapi.json", "/metrics", "/api/v1/system/health"}
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
response = await call_next(request)
if request.method in WRITE_METHODS and not self._should_ignore(request.url.path):
try:
await self._record_audit(request, response)
except Exception as exc:
logger.warning("audit_log_failed", extra={"error": str(exc)})
return response
def _should_ignore(self, path: str) -> bool:
return any(path.startswith(p) for p in IGNORED_PATHS)
async def _record_audit(self, request: Request, response: Response) -> None:
actor_id = None
actor_type = "anonymous"
if hasattr(request.state, "user") and request.state.user:
user = request.state.user
try:
actor_id = uuid.UUID(user.subject)
except (ValueError, TypeError):
pass
actor_type = user.role.value if hasattr(user.role, "value") else str(user.role)
resource_type = self._extract_resource_type(request.url.path)
action = f"{request.method.lower()}_{resource_type}"
resource_id = self._extract_resource_id(request.url.path)
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
async with async_session_factory() as session:
try:
from src.infrastructure.database.repositories import AuditLogRepository
repo = AuditLogRepository(session)
entity = AuditLogEntity(
actor_id=actor_id,
actor_type=actor_type,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
)
await repo.add(entity)
await session.commit()
except Exception as exc:
logger.warning("audit_db_write_failed", extra={"error": str(exc)})
def _extract_resource_type(self, path: str) -> str:
parts = path.strip("/").split("/")
if len(parts) >= 3:
return parts[2]
if len(parts) >= 1:
return parts[-1]
return "unknown"
def _extract_resource_id(self, path: str) -> str | None:
parts = path.strip("/").split("/")
for part in reversed(parts[3:] if len(parts) > 3 else parts):
try:
uuid.UUID(part)
return part
except ValueError:
continue
return None
@@ -0,0 +1,36 @@
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from src.core.logging import get_logger
logger = get_logger("request_logging")
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.state.request_id = request_id
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
logger.info(
"request_completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(duration_ms, 2),
"client_ip": request.client.host if request.client else None,
"user_agent": request.headers.get("user-agent", ""),
},
)
response.headers["X-Request-ID"] = request_id
return response
@@ -0,0 +1,53 @@
import time
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from src.infrastructure.config.settings import settings
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, requests_per_minute: int | None = None):
super().__init__(app)
self._rpm = requests_per_minute or settings.RATE_LIMIT_PER_MINUTE
self._clients: dict[str, list[float]] = {}
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if request.method == "OPTIONS":
return await call_next(request)
path = request.url.path
if path.startswith("/docs") or path.startswith("/redoc") or path.startswith("/openapi") or path.startswith("/metrics"):
return await call_next(request)
client_key = self._get_client_key(request)
now = time.monotonic()
window = self._clients.setdefault(client_key, [])
window[:] = [t for t in window if now - t < 60]
if len(window) >= self._rpm:
return Response(
content='{"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Rate limit exceeded. Try again later."}}',
status_code=429,
media_type="application/json",
headers={"Retry-After": "60"},
)
window.append(now)
response = await call_next(request)
remaining = self._rpm - len(window)
response.headers["X-RateLimit-Limit"] = str(self._rpm)
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
response.headers["X-RateLimit-Reset"] = str(int(time.time()) + 60)
return response
@staticmethod
def _get_client_key(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"