feat(subsonic): response envelope, id scheme, and error mapping
- envelope: one serializer emitting the <subsonic-response> wrapper in XML (default) and JSON (f=json), carrying status/version/type/serverVersion - ids: stable, reversible type-prefixed ids (tr-/al-/ar-/pl-) ↔ UUIDs - errors: /rest requests render the Subsonic error envelope (always HTTP 200) with standard codes (10 missing param, 40 wrong creds, 50, 70 not found) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+33
-4
@@ -1,8 +1,15 @@
|
||||
"""Maps domain exceptions to HTTP responses. The only place that knows both."""
|
||||
"""Maps domain exceptions to HTTP responses. The only place that knows both.
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
Two surfaces share this mapping: the native ``/api/v1`` API answers with a JSON
|
||||
error body and an HTTP status code, while the Subsonic ``/rest`` layer answers
|
||||
with its own envelope and **always HTTP 200** (the status lives in the body). A
|
||||
request is routed to the Subsonic renderer by path prefix.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, Request, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.rest.envelope import subsonic_error
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.errors import (
|
||||
AlreadyExistsError,
|
||||
@@ -30,6 +37,21 @@ _STATUS_BY_ERROR: dict[type[DomainError], int] = {
|
||||
StorageError: status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
|
||||
# Subsonic error codes (subsonic.org/restapi): 10 missing param, 40 wrong
|
||||
# credentials, 50 not authorized, 70 not found, 0 generic.
|
||||
_SUBSONIC_CODE_BY_ERROR: dict[type[DomainError], int] = {
|
||||
ValidationError: 10,
|
||||
AuthenticationError: 40,
|
||||
PermissionDeniedError: 50,
|
||||
NotFoundError: 70,
|
||||
}
|
||||
|
||||
_SUBSONIC_PREFIX = "/rest"
|
||||
|
||||
|
||||
def _is_subsonic(request: Request) -> bool:
|
||||
return request.url.path.startswith(_SUBSONIC_PREFIX)
|
||||
|
||||
|
||||
def _error_body(code: str, message: str) -> dict[str, dict[str, str]]:
|
||||
return {"error": {"code": code, "message": message}}
|
||||
@@ -45,7 +67,10 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
)
|
||||
|
||||
@app.exception_handler(DomainError)
|
||||
async def _handle_domain_error(_request: Request, exc: DomainError) -> JSONResponse:
|
||||
async def _handle_domain_error(request: Request, exc: DomainError) -> Response:
|
||||
if _is_subsonic(request):
|
||||
code = _SUBSONIC_CODE_BY_ERROR.get(type(exc), 0)
|
||||
return subsonic_error(code, exc.message, fmt=request.query_params.get("f"))
|
||||
http_status = _STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST)
|
||||
return JSONResponse(
|
||||
status_code=http_status,
|
||||
@@ -53,8 +78,12 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _handle_unexpected(_request: Request, exc: Exception) -> JSONResponse:
|
||||
async def _handle_unexpected(request: Request, exc: Exception) -> Response:
|
||||
log.error("unhandled_exception", exc_info=exc)
|
||||
if _is_subsonic(request):
|
||||
return subsonic_error(
|
||||
0, "An unexpected error occurred.", fmt=request.query_params.get("f")
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=_error_body("internal_error", "An unexpected error occurred."),
|
||||
|
||||
Reference in New Issue
Block a user