Primera versión del backend
This commit is contained in:
@@ -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()
|
||||
@@ -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()),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user