Commit 5ca1180d authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: implement RFC 7807 problem responses and exception handlers for API errors

parent 6d20edbc
Loading
Loading
Loading
Loading
+101 −0
Changes for src/portal_service/api/errors.py: 101 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""RFC 7807 problem responses for the errors FastAPI would otherwise answer in its own shape.

Every portal API error is RFC 7807 `application/problem+json`, as in SRM. Routes refuse with
HTTPException as usual; this turns those, and request validation, which FastAPI answers as
`{"detail": [...]}`, into problem documents.

Unlike SRM, an unrouted path is a problem document too. SRM keeps FastAPI's own 404 shape
because OEG tells "no such subscriber" apart from "no such route" by content type alone; the
portal's only client is its own GUI, which needs no such signal, so one shape covers all.
"""

from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response
from starlette.exceptions import HTTPException as StarletteHTTPException

from portal_service.api.schemas import ProblemDetail

PROBLEM_BASE = "https://etsi.org/sdg/oop/problems"
PROBLEM_MEDIA_TYPE = "application/problem+json"
INVALID_REQUEST = f"{PROBLEM_BASE}/invalid-request"

_MAX_DETAIL = 512


def _title_for_status(status_code: int) -> str:
    return {
        status.HTTP_400_BAD_REQUEST: "Bad Request",
        status.HTTP_401_UNAUTHORIZED: "Unauthorized",
        status.HTTP_403_FORBIDDEN: "Forbidden",
        status.HTTP_404_NOT_FOUND: "Not Found",
        status.HTTP_405_METHOD_NOT_ALLOWED: "Method Not Allowed",
        status.HTTP_409_CONFLICT: "Conflict",
        status.HTTP_422_UNPROCESSABLE_CONTENT: "Unprocessable Entity",
        status.HTTP_502_BAD_GATEWAY: "Bad Gateway",
        status.HTTP_503_SERVICE_UNAVAILABLE: "Service Unavailable",
    }.get(status_code, "HTTP Error")


def problem_response(
    *,
    status_code: int,
    type_uri: str,
    title: str,
    detail: str | None = None,
    correlator: str | None = None,
) -> JSONResponse:
    response = JSONResponse(
        status_code=status_code,
        content=ProblemDetail(
            type=type_uri,
            title=title,
            status=status_code,
            detail=detail,
        ).model_dump(mode="json", exclude_none=True),
        media_type=PROBLEM_MEDIA_TYPE,
    )
    if correlator is not None:
        response.headers["x-correlator"] = correlator
    return response


def _detail(exc: RequestValidationError) -> str:
    # `loc` opens with "body"/"query"; the useful part is the canonical field path after it.
    parts = [
        f"{'.'.join(str(segment) for segment in error['loc'][1:]) or 'body'}: {error['msg']}"
        for error in exc.errors()
    ]
    return "; ".join(parts)[:_MAX_DETAIL]


def register_exception_handlers(app: FastAPI) -> None:
    @app.exception_handler(StarletteHTTPException)
    async def handle_http_exception(
        request: Request,
        exc: StarletteHTTPException,
    ) -> Response:
        detail = exc.detail if isinstance(exc.detail, str) else None
        response = problem_response(
            status_code=exc.status_code,
            type_uri="about:blank",
            title=_title_for_status(exc.status_code),
            detail=detail,
            correlator=request.headers.get("x-correlator"),
        )
        if exc.headers:
            response.headers.update(exc.headers)
        return response

    @app.exception_handler(RequestValidationError)
    async def handle_request_validation_error(
        request: Request,
        exc: RequestValidationError,
    ) -> JSONResponse:
        return problem_response(
            status_code=status.HTTP_400_BAD_REQUEST,
            type_uri=INVALID_REQUEST,
            title="Request failed validation.",
            detail=_detail(exc),
            correlator=request.headers.get("x-correlator"),
        )
+0 −34
Changes for src/portal_service/api/exception_handlers.py: 0 added lines, 34 removed lines.
Original line number Diff line number Diff line
"""
RFC 7807 Problem Details exception handlers.

Domain errors raised by framework-free layers are translated to
application/problem+json here. This module introduces the `_problem` helper and
the registration seam; concrete handlers are added alongside the errors that
need them.

Auth/authorization failures are handled inline in the api/auth layer via
HTTPException, so they need no domain-error handler.
"""

from fastapi import FastAPI
from fastapi.responses import JSONResponse

_PROBLEM_MEDIA_TYPE = "application/problem+json"


def _problem(status: int, title: str, detail: str, instance: str) -> JSONResponse:
    return JSONResponse(
        status_code=status,
        media_type=_PROBLEM_MEDIA_TYPE,
        content={
            "type": "about:blank",
            "title": title,
            "status": status,
            "detail": detail,
            "instance": instance,
        },
    )


def register_exception_handlers(app: FastAPI) -> None:
    return None
+12 −0
Changes for src/portal_service/api/schemas.py: 12 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""Response shapes shared across the portal API."""

from pydantic import BaseModel


class ProblemDetail(BaseModel):
    """RFC 7807 problem details, the shape of every portal API error."""

    type: str
    title: str
    status: int
    detail: str | None = None
+1 −1
Changes for src/portal_service/main.py: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -19,7 +19,7 @@ from portal_service.adapters.database.core import (
    schema_initialization,
)
from portal_service.api.auth.routes import auth_router
from portal_service.api.exception_handlers import register_exception_handlers
from portal_service.api.errors import register_exception_handlers
from portal_service.api.health import health_router
from portal_service.api.middlewares import register_middlewares
from portal_service.background import cleanup_loop
+122 −0
Changes for tests/test_rfc7807_errors.py: 122 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""
Every portal API error is an RFC 7807 problem document.

The first tests mirror SRM's (tests/api/rest/test_rfc7807_errors.py); the rest check the
portal's own routes, which are what the GUI actually receives.
"""

from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient, Response

from portal_service.api.dependencies import get_identity_provider, get_login_session_repo
from portal_service.api.errors import register_exception_handlers
from tests.fakes import FakeIdentityProvider, FakeLoginSessionRepository


def _assert_problem(
    response: Response,
    *,
    status_code: int,
    title: str,
    detail: str,
    type_uri: str = "about:blank",
) -> None:
    assert response.status_code == status_code
    assert response.headers["content-type"].startswith("application/problem+json")
    assert response.json() == {
        "type": type_uri,
        "title": title,
        "status": status_code,
        "detail": detail,
    }


async def test_http_exception_from_a_route_is_an_rfc7807_problem() -> None:
    app = FastAPI()
    register_exception_handlers(app)

    @app.get("/handled")
    async def handled() -> None:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="Already exists.",
            headers={"retry-after": "10"},
        )

    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.get("/handled", headers={"x-correlator": "req-abc"})

    _assert_problem(response, status_code=409, title="Conflict", detail="Already exists.")
    assert response.headers["x-correlator"] == "req-abc"
    assert response.headers["retry-after"] == "10"


async def test_validation_error_is_an_invalid_request_problem() -> None:
    app = FastAPI()
    register_exception_handlers(app)

    @app.get("/items")
    async def items(limit: int) -> None:
        return None

    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.get("/items", params={"limit": "not-a-number"})

    assert response.status_code == 400
    assert response.headers["content-type"].startswith("application/problem+json")
    body = response.json()
    assert body["type"] == "https://etsi.org/sdg/oop/problems/invalid-request"
    assert body["title"] == "Request failed validation."
    assert body["detail"].startswith("limit:")


async def test_unrouted_path_is_a_problem_too() -> None:
    # The one deliberate difference from SRM: see api/errors.py.
    app = FastAPI()
    register_exception_handlers(app)

    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.get("/no-such-route")

    _assert_problem(response, status_code=404, title="Not Found", detail="Not Found")


# ------------------------------------------------------------ the portal's routes


def test_me_without_a_session_is_an_unauthorized_problem(client_jwt: TestClient) -> None:
    app = client_jwt.app
    assert isinstance(app, FastAPI)
    app.dependency_overrides[get_login_session_repo] = lambda: FakeLoginSessionRepository()
    app.dependency_overrides[get_identity_provider] = lambda: FakeIdentityProvider()

    response = client_jwt.get("/api/auth/me", headers={"x-correlator": "req-me"})
    app.dependency_overrides.clear()

    _assert_problem(response, status_code=401, title="Unauthorized", detail="Not authenticated")
    assert response.headers["x-correlator"] == "req-me"


def test_wrong_method_is_a_problem(client_jwt: TestClient) -> None:
    response = client_jwt.get("/api/auth/logout")

    _assert_problem(
        response, status_code=405, title="Method Not Allowed", detail="Method Not Allowed"
    )


def test_bearer_challenge_header_survives(client_jwt: TestClient) -> None:
    # The bearer path's 401 carries WWW-Authenticate; the problem response must keep it.
    app = client_jwt.app
    assert isinstance(app, FastAPI)
    from portal_service.api.auth import CurrentUserDep

    @app.get("/test-bearer-only")
    async def bearer_only(user: CurrentUserDep) -> None:
        return None

    response = client_jwt.get("/test-bearer-only")

    _assert_problem(response, status_code=401, title="Unauthorized", detail="Not authenticated")
    assert response.headers["www-authenticate"] == "Bearer"