feat(api): add POST/GET /v1/files with auth, error envelope, and request-id middleware

Why:
- Wires the ADR-0008 error envelope, per-request correlation id, and the
  /v1/files routes into the app.

Changes:
- Extend AppResources/lifespan with the ingestion CapacityLimiter and
  ObjectStorage adapter.

Impact:
- /v1 now exposes routes for the first time.
This commit is contained in:
2026-08-19 15:00:39 +03:30
parent c9cf7b368b
commit 3bced65926
10 changed files with 355 additions and 1 deletions

View File

@@ -0,0 +1,38 @@
"""Auth dependencies (ADR-0008): resolve `AuthContext` from a bearer token,
then gate routes on scope.
"""
from collections.abc import Awaitable, Callable
from typing import Annotated
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.auth.context import AuthContext
from src.application.auth.errors import InvalidApiKeyError, MissingScopeError
from src.application.auth.service import resolve_auth_context
from src.bootstrap.dependencies import get_sessionmaker
_bearer_scheme = HTTPBearer(auto_error=False)
async def get_auth_context(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)],
sessionmaker: Annotated[async_sessionmaker[AsyncSession], Depends(get_sessionmaker)],
) -> AuthContext:
if credentials is None:
raise InvalidApiKeyError("missing Authorization header")
return await resolve_auth_context(sessionmaker, credentials.credentials)
AuthContextDep = Annotated[AuthContext, Depends(get_auth_context)]
def require_scope(scope: str) -> Callable[[AuthContext], Awaitable[AuthContext]]:
async def _dependency(auth: AuthContextDep) -> AuthContext:
if not auth.has_scope(scope):
raise MissingScopeError(f"missing required scope '{scope}'")
return auth
return _dependency

97
src/api/errors.py Normal file
View File

@@ -0,0 +1,97 @@
"""Maps application exceptions to the ADR-0008 error envelope.
This is the single place that knows the exception-type -> status-code
mapping; application/infrastructure code never imports FastAPI or raises
`HTTPException` (ADR-0015).
"""
import structlog
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from src.application.auth.errors import (
InvalidApiKeyError,
MissingScopeError,
TenantInactiveError,
)
from src.application.files.errors import FileTooLargeError, InvalidUploadError
from src.application.ingestion.errors import DocumentParseError, UnsupportedSourceTypeError
logger = structlog.get_logger(__name__)
# (exception type, status code, stable error code)
_MAPPING: tuple[tuple[type[Exception], int, str], ...] = (
(InvalidApiKeyError, status.HTTP_401_UNAUTHORIZED, "invalid_api_key"),
(TenantInactiveError, status.HTTP_401_UNAUTHORIZED, "tenant_not_found"),
(MissingScopeError, status.HTTP_403_FORBIDDEN, "missing_scope"),
(InvalidUploadError, status.HTTP_400_BAD_REQUEST, "validation_error"),
(DocumentParseError, status.HTTP_400_BAD_REQUEST, "validation_error"),
(UnsupportedSourceTypeError, status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "unsupported_media_type"),
(FileTooLargeError, status.HTTP_413_CONTENT_TOO_LARGE, "payload_too_large"),
)
def _request_id(request: Request) -> str | None:
return getattr(request.state, "request_id", None)
def _envelope(
code: str, message: str, request_id: str | None, details: dict[str, object] | None = None
) -> dict[str, object]:
return {
"error": {
"code": code,
"message": message,
"details": details or {},
"request_id": request_id,
}
}
def register_exception_handlers(app: FastAPI) -> None:
for exc_type, status_code, error_code in _MAPPING:
def _handler(
request: Request,
exc: Exception,
status_code: int = status_code,
error_code: str = error_code,
) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content=_envelope(error_code, str(exc), _request_id(request)),
)
app.add_exception_handler(exc_type, _handler)
@app.exception_handler(RequestValidationError)
def _validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=_envelope(
"validation_error",
"request validation failed",
_request_id(request),
details={"errors": exc.errors()},
),
)
@app.exception_handler(StarletteHTTPException)
def _http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
code = "not_found" if exc.status_code == status.HTTP_404_NOT_FOUND else "http_error"
return JSONResponse(
status_code=exc.status_code,
content=_envelope(code, str(exc.detail), _request_id(request)),
)
@app.exception_handler(Exception)
def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception("api.unhandled_exception", path=request.url.path)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_envelope(
"internal_error", "an unexpected error occurred", _request_id(request)
),
)

38
src/api/middleware.py Normal file
View File

@@ -0,0 +1,38 @@
"""Per-request correlation id (ADR-0008, ADR-0011).
Every request gets a `request_id`: reused from an incoming `X-Request-Id` if
the caller supplied one, otherwise generated. It is bound into structlog's
contextvars so every log line emitted while handling the request carries it,
stored on `request.state` for exception handlers, and echoed back in the
response header.
"""
import uuid
from collections.abc import Awaitable, Callable
from typing import override
import structlog
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
_HEADER = "X-Request-Id"
class RequestIdMiddleware(BaseHTTPMiddleware):
@override
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
request_id = request.headers.get(_HEADER) or str(uuid.uuid4())
request.state.request_id = request_id
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(request_id=request_id)
try:
response = await call_next(request)
finally:
structlog.contextvars.clear_contextvars()
response.headers[_HEADER] = request_id
return response

View File

@@ -1,3 +1,6 @@
from fastapi import APIRouter
from src.api.routers.files import router as files_router
router = APIRouter()
router.include_router(files_router)

74
src/api/routers/files.py Normal file
View File

@@ -0,0 +1,74 @@
"""`POST /v1/files`, `GET /v1/files/{file_id}` (ADR-0008).
Routes adapt HTTP to `application/files` calls; they do not parse, hash,
touch MinIO/Qdrant, or otherwise carry ingestion business logic (ADR-0015).
"""
import uuid
from typing import Annotated
from anyio import CapacityLimiter
from fastapi import APIRouter, Depends, Form, HTTPException, Response, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.api.dependencies.auth import require_scope
from src.api.schemas.files import FileStatusResponse, FileUploadResponse
from src.application.auth.context import AuthContext
from src.application.files.status import get_file_status
from src.application.files.upload import upload_source_file
from src.application.ports.object_storage import ObjectStorage
from src.bootstrap.dependencies import (
get_ingestion_limiter,
get_object_storage,
get_sessionmaker,
get_settings,
)
from src.config import Settings
router = APIRouter(prefix="/files", tags=["files"])
_RequireFilesWrite = Annotated[AuthContext, Depends(require_scope("files:write"))]
_SessionmakerDep = Annotated[async_sessionmaker[AsyncSession], Depends(get_sessionmaker)]
_ObjectStorageDep = Annotated[ObjectStorage, Depends(get_object_storage)]
_SettingsDep = Annotated[Settings, Depends(get_settings)]
_IngestionLimiterDep = Annotated[CapacityLimiter, Depends(get_ingestion_limiter)]
@router.post("", status_code=status.HTTP_201_CREATED)
async def upload_file(
response: Response,
file: UploadFile,
domain: Annotated[str, Form()],
auth: _RequireFilesWrite,
sessionmaker: _SessionmakerDep,
storage: _ObjectStorageDep,
settings: _SettingsDep,
limiter: _IngestionLimiterDep,
) -> FileUploadResponse:
data = await file.read()
result = await upload_source_file(
sessionmaker=sessionmaker,
storage=storage,
auth=auth,
domain=domain,
filename=file.filename or "",
data=data,
max_upload_size_bytes=settings.ingestion.max_upload_size_bytes,
chunking_strategy=settings.chunking.strategy,
validation_limiter=limiter,
)
if not result.is_new_attempt:
response.status_code = status.HTTP_200_OK
return FileUploadResponse.from_result(result)
@router.get("/{file_id}")
async def get_file(
file_id: uuid.UUID,
auth: _RequireFilesWrite,
sessionmaker: _SessionmakerDep,
) -> FileStatusResponse:
result = await get_file_status(sessionmaker, tenant_id=auth.tenant_id, source_file_id=file_id)
if result is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
return FileStatusResponse.from_result(result)

16
src/api/schemas/errors.py Normal file
View File

@@ -0,0 +1,16 @@
"""The ADR-0008 error envelope."""
from typing import Any
from pydantic import BaseModel
class ErrorDetail(BaseModel):
code: str
message: str
details: dict[str, Any] = {}
request_id: str | None = None
class ErrorResponse(BaseModel):
error: ErrorDetail

50
src/api/schemas/files.py Normal file
View File

@@ -0,0 +1,50 @@
"""Public request/response models for `/v1/files` (ADR-0008).
Separate from the SQLAlchemy ORM models and the `application/files` domain
dataclasses (ADR-0015): this is the shape callers see.
"""
import uuid
from pydantic import BaseModel
from src.application.files.models import UploadResult
from src.application.files.status import FileStatusResult
class FileUploadResponse(BaseModel):
file_id: uuid.UUID
ingestion_job_id: uuid.UUID
status: str
chunks_indexed: int
@classmethod
def from_result(cls, result: UploadResult) -> "FileUploadResponse":
return cls(
file_id=result.file_id,
ingestion_job_id=result.ingestion_job_id,
status=result.status,
chunks_indexed=result.chunks_indexed,
)
class FileStatusResponse(BaseModel):
file_id: uuid.UUID
source_filename: str
domain: str
status: str
ingestion_job_id: uuid.UUID | None
ingestion_status: str | None
chunks_indexed: int
@classmethod
def from_result(cls, result: FileStatusResult) -> "FileStatusResponse":
return cls(
file_id=result.file_id,
source_filename=result.source_filename,
domain=result.domain,
status=result.status,
ingestion_job_id=result.ingestion_job_id,
ingestion_status=result.ingestion_status,
chunks_indexed=result.chunks_indexed,
)