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:
97
src/api/errors.py
Normal file
97
src/api/errors.py
Normal 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)
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user