Primera versión del backend

This commit is contained in:
Mireya Cueto Garrido
2026-05-13 13:43:32 +02:00
parent 7d893c98fa
commit ebc3631cfd
32 changed files with 1264 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "GenExamenes IA"
environment: str = "local"
api_prefix: str = ""
api_key: str = Field(min_length=16)
database_url: str = "postgresql+psycopg://genexamenes:genexamenes@localhost:5432/genexamenes"
allowed_origins: str = "http://localhost:3000"
rate_limit_requests: int = Field(default=60, ge=1)
rate_limit_window_seconds: int = Field(default=60, ge=1)
max_request_bytes: int = Field(default=1_048_576, ge=1_024)
llm_api_key: str | None = None
llm_base_url: str = "https://api.openai.com/v1"
llm_model: str = "gpt-4o-mini"
llm_timeout_seconds: int = Field(default=60, ge=5)
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
@property
def cors_origins(self) -> list[str]:
return [origin.strip() for origin in self.allowed_origins.split(",") if origin.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
+56
View File
@@ -0,0 +1,56 @@
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import ORJSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
class AppError(Exception):
def __init__(self, message: str, status_code: int = 400, code: str = "app_error") -> None:
self.message = message
self.status_code = status_code
self.code = code
class NotFoundError(AppError):
def __init__(self, message: str = "Resource not found") -> None:
super().__init__(message=message, status_code=404, code="not_found")
class LLMUnavailableError(AppError):
def __init__(self, message: str = "LLM service is unavailable") -> None:
super().__init__(message=message, status_code=503, code="llm_unavailable")
class ParseError(AppError):
def __init__(self, message: str = "Unable to parse AI output") -> None:
super().__init__(message=message, status_code=422, code="parse_error")
def error_payload(code: str, message: str, details: object | None = None) -> dict[str, object]:
payload: dict[str, object] = {"error": {"code": code, "message": message}}
if details is not None:
payload["error"]["details"] = details
return payload
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def app_error_handler(_: Request, exc: AppError) -> ORJSONResponse:
return ORJSONResponse(
status_code=exc.status_code,
content=error_payload(exc.code, exc.message),
)
@app.exception_handler(StarletteHTTPException)
async def http_error_handler(_: Request, exc: StarletteHTTPException) -> ORJSONResponse:
return ORJSONResponse(
status_code=exc.status_code,
content=error_payload("http_error", str(exc.detail)),
)
@app.exception_handler(RequestValidationError)
async def validation_error_handler(_: Request, exc: RequestValidationError) -> ORJSONResponse:
return ORJSONResponse(
status_code=422,
content=error_payload("validation_error", "Invalid request payload", exc.errors()),
)
+50
View File
@@ -0,0 +1,50 @@
import time
from collections import defaultdict, deque
from fastapi import Request, Response
from fastapi.responses import ORJSONResponse
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from app.core.config import Settings
from app.core.errors import error_payload
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app: object, settings: Settings) -> None:
super().__init__(app)
self.limit = settings.rate_limit_requests
self.window_seconds = settings.rate_limit_window_seconds
self.requests: defaultdict[str, deque[float]] = defaultdict(deque)
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
client = request.client.host if request.client else "unknown"
now = time.monotonic()
bucket = self.requests[client]
while bucket and now - bucket[0] > self.window_seconds:
bucket.popleft()
if len(bucket) >= self.limit:
return ORJSONResponse(
status_code=429,
content=error_payload("rate_limited", "Too many requests"),
headers={"Retry-After": str(self.window_seconds)},
)
bucket.append(now)
return await call_next(request)
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app: object, settings: Settings) -> None:
super().__init__(app)
self.max_request_bytes = settings.max_request_bytes
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
content_length = request.headers.get("content-length")
if content_length and int(content_length) > self.max_request_bytes:
return ORJSONResponse(
status_code=413,
content=error_payload("payload_too_large", "Request body is too large"),
)
return await call_next(request)
+41
View File
@@ -0,0 +1,41 @@
import re
from html import escape
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from app.core.config import Settings, get_settings
CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
ROLE_INJECTION_HINTS = re.compile(
r"(ignore\s+(all\s+)?previous|system\s*:|developer\s*:|act\s+as\s+system)",
flags=re.IGNORECASE,
)
def require_api_key(
settings: Annotated[Settings, Depends(get_settings)],
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
) -> None:
if not x_api_key or x_api_key != settings.api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
)
def clean_text(value: str, *, max_length: int = 8_000) -> str:
cleaned = CONTROL_CHARS.sub("", value).strip()
if len(cleaned) > max_length:
cleaned = cleaned[:max_length].strip()
return cleaned
def sanitize_prompt_input(value: str) -> str:
cleaned = clean_text(value, max_length=4_000)
return ROLE_INJECTION_HINTS.sub("[filtered instruction]", cleaned)
def html_text(value: str) -> str:
return escape(clean_text(value), quote=True)