Commit eb1a3b45 authored by George Papathanail's avatar George Papathanail
Browse files

fix(fm): allow uknown fields in federation request bodies

parent 561c74d1
Loading
Loading
Loading
Loading
Loading
+58 −80
Changes for src/open_exposure_gateway/api/error_handlers.py: 58 added lines, 80 removed lines.
Original line number Diff line number Diff line
import uuid
from collections.abc import Mapping

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

from open_exposure_gateway.api.camara.common import X_CORRELATOR_PATTERN
from open_exposure_gateway.api.gsma.federation_manager.v1_4_0 import problems
from open_exposure_gateway.core.exceptions import (
    BadRequestException,
    ConflictException,
@@ -48,112 +50,94 @@ def x_correlator_header(request: Request) -> str:
    return x_correlator


def _error_response(
    request: Request,
    status: int,
    code: ErrorCode,
    message: str,
    headers: Mapping[str, str] | None = None,
) -> Response:
    """CAMARA ``ErrorInfo``, or on the federation-lifecycle routes a GSMA ``ProblemDetails``.

    §K.6a: every error on those routes is ``application/problem+json``, including the
    ones raised before or outside the route handler that land here. ``message`` is never
    used there -- ProblemDetails carries a fixed ``detail`` per status (§K.6a).
    """
    response_headers = {"x-correlator": x_correlator_header(request), **(headers or {})}
    if problems.is_federation_path(request.url.path):
        return problems.problem_response(
            problems.problem_for_status(status), headers=response_headers
        )
    return JSONResponse(
        status_code=status,
        headers=response_headers,
        content=ErrorInfo(status=status, code=code, message=message).model_dump(),
    )


def register_exception_handlers(app: FastAPI) -> None:
    @app.exception_handler(RequestValidationError)
    async def handle_request_validation_error(
        request: Request, exc: RequestValidationError
    ) -> JSONResponse:
        return JSONResponse(
            status_code=400,
    ) -> Response:
        if problems.is_federation_path(request.url.path):
            return problems.problem_response(
                problems.INVALID_PARAMETERS,
                problems.invalid_params(exc.errors()),
                headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(
                status=400,
                code=ErrorCode.INVALID_ARGUMENT,
                message=str(exc.errors())[:512],
            ).model_dump(),
            )
        return _error_response(request, 400, ErrorCode.INVALID_ARGUMENT, str(exc.errors())[:512])

    @app.exception_handler(BadRequestException)
    async def handle_bad_request(request: Request, exc: BadRequestException) -> JSONResponse:
        return JSONResponse(
            status_code=400,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=400, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_bad_request(request: Request, exc: BadRequestException) -> Response:
        return _error_response(request, 400, exc.error_code, exc.message)

    @app.exception_handler(OutOfRangeException)
    async def handle_out_of_range(request: Request, exc: OutOfRangeException) -> JSONResponse:
        return JSONResponse(
            status_code=400,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=400, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_out_of_range(request: Request, exc: OutOfRangeException) -> Response:
        return _error_response(request, 400, exc.error_code, exc.message)

    @app.exception_handler(UnauthorizedException)
    async def handle_unauthorized(request: Request, exc: UnauthorizedException) -> JSONResponse:
        return JSONResponse(
            status_code=401,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=401, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_unauthorized(request: Request, exc: UnauthorizedException) -> Response:
        return _error_response(request, 401, exc.error_code, exc.message)

    @app.exception_handler(ForbiddenException)
    async def handle_forbidden(request: Request, exc: ForbiddenException) -> JSONResponse:
        return JSONResponse(
            status_code=403,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=403, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_forbidden(request: Request, exc: ForbiddenException) -> Response:
        return _error_response(request, 403, exc.error_code, exc.message)

    @app.exception_handler(NotFoundException)
    async def handle_not_found(request: Request, exc: NotFoundException) -> JSONResponse:
        return JSONResponse(
            status_code=404,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=404, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_not_found(request: Request, exc: NotFoundException) -> Response:
        return _error_response(request, 404, exc.error_code, exc.message)

    @app.exception_handler(ConflictException)
    async def handle_conflict(request: Request, exc: ConflictException) -> JSONResponse:
        return JSONResponse(
            status_code=409,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=409, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_conflict(request: Request, exc: ConflictException) -> Response:
        return _error_response(request, 409, exc.error_code, exc.message)

    @app.exception_handler(NotImplementedException)
    async def handle_not_implemented(
        request: Request, exc: NotImplementedException
    ) -> JSONResponse:
        return JSONResponse(
            status_code=501,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=501, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_not_implemented(request: Request, exc: NotImplementedException) -> Response:
        return _error_response(request, 501, exc.error_code, exc.message)

    @app.exception_handler(DownstreamServiceException)
    async def handle_downstream(request: Request, exc: DownstreamServiceException) -> JSONResponse:
        return JSONResponse(
            status_code=503,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=503, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_downstream(request: Request, exc: DownstreamServiceException) -> Response:
        return _error_response(request, 503, exc.error_code, exc.message)

    @app.exception_handler(OEGException)
    async def handle_oeg_exception(request: Request, exc: OEGException) -> JSONResponse:
        return JSONResponse(
            status_code=exc.status_code,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(
                status=exc.status_code, code=exc.error_code, message=exc.message
            ).model_dump(),
        )
    async def handle_oeg_exception(request: Request, exc: OEGException) -> Response:
        return _error_response(request, exc.status_code, exc.error_code, exc.message)

    @app.exception_handler(StarletteHTTPException)
    async def handle_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse:
    async def handle_http_exception(request: Request, exc: StarletteHTTPException) -> Response:
        exc_cls = _HTTP_EXCEPTION_MAP.get(exc.status_code)
        mapped = (
            exc_cls(message=str(exc.detail))
            if exc_cls is not None
            else OEGException(message=str(exc.detail), status_code=exc.status_code)
        )
        response = await handle_oeg_exception(request, mapped)
        if exc.headers:
            response.headers.update(exc.headers)
        return response
        return _error_response(
            request, mapped.status_code, mapped.error_code, mapped.message, exc.headers
        )

    @app.exception_handler(Exception)
    async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse:
    async def handle_unexpected_exception(request: Request, exc: Exception) -> Response:
        x_correlator = x_correlator_header(request)
        structlog.get_logger().exception(
            "Unhandled exception",
@@ -163,10 +147,4 @@ def register_exception_handlers(app: FastAPI) -> None:
            x_correlator=x_correlator,
            error=str(exc),
        )
        return JSONResponse(
            status_code=500,
            headers={"x-correlator": x_correlator},
            content=ErrorInfo(
                status=500, code=ErrorCode.INTERNAL, message="Internal server error"
            ).model_dump(),
        )
        return _error_response(request, 500, ErrorCode.INTERNAL, "Internal server error")
+175 −0
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/problems.py: 175 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""OEG-authored ``ProblemDetails`` for the federation-lifecycle surface (§K.6a, ADR-0050).

Lives apart from ``router.py`` so ``api/error_handlers.py`` can use it too: the router
imports ``error_handlers``, so ``error_handlers`` importing the router would be circular.
"""

from collections.abc import Iterable, Mapping, Sequence
from http import HTTPStatus
from typing import Any, NamedTuple

from fastapi import Response

from open_exposure_gateway.api.gsma.federation_manager.v1_4_0.schemas import (
    InvalidParam,
    ProblemDetails,
)

# OEG's own platform namespace, mirrored 1:1 at FM's `/internal` (FmClient does a
# prefix swap, nothing else -- see fm_client.py).
BASE_PATH = "/platform/v1/federation"


class OegProblem(NamedTuple):
    """Fixed template for one kind of OEG-authored error (§K.6a)."""

    status: int
    type: str
    title: str
    detail: str
    cause: str


# Every error OEG itself raises on this surface. `detail` is a fixed sentence per case
# -- never exception text or upstream diagnostics (§K.6a).
MALFORMED_BODY = OegProblem(
    400,
    "urn:oop:ewbi:error:malformed-request-body",
    "Malformed request body",
    "Request body is not valid JSON.",
    "MALFORMED_REQUEST_BODY",
)
INVALID_BODY = OegProblem(
    400,
    "urn:oop:ewbi:error:invalid-request-body",
    "Request body does not match the expected schema",
    "One or more request body fields are invalid; see invalidParams.",
    "INVALID_REQUEST_BODY",
)
INVALID_PARAMETERS = OegProblem(
    400,
    "urn:oop:ewbi:error:invalid-request-parameters",
    "Invalid request parameters",
    "One or more path, query or header parameters are invalid; see invalidParams.",
    "INVALID_REQUEST_PARAMETERS",
)
UPSTREAM_UNAVAILABLE = OegProblem(
    503,
    "urn:oop:ewbi:error:upstream-unavailable",
    "Federation Manager unavailable",
    "The federation-lifecycle request could not be forwarded to FM.",
    "UPSTREAM_UNAVAILABLE",
)
UPSTREAM_BAD_RESPONSE = OegProblem(
    502,
    "urn:oop:ewbi:error:upstream-bad-response",
    "Federation Manager returned an unusable response",
    "FM's response could not be relayed.",
    "UPSTREAM_BAD_RESPONSE",
)
INTERNAL_ERROR = OegProblem(
    500,
    "urn:oop:ewbi:error:internal-error",
    "Internal server error",
    "The request could not be processed.",
    "INTERNAL_ERROR",
)

# Framework-level errors (raised before or outside a route handler), by status.
_BY_STATUS = {
    401: OegProblem(
        401,
        "urn:oop:ewbi:error:unauthenticated",
        "Unauthenticated",
        "The request lacks valid authentication credentials.",
        "UNAUTHENTICATED",
    ),
    403: OegProblem(
        403,
        "urn:oop:ewbi:error:permission-denied",
        "Permission denied",
        "The caller is not allowed to perform this operation.",
        "PERMISSION_DENIED",
    ),
    404: OegProblem(
        404,
        "urn:oop:ewbi:error:not-found",
        "Not found",
        "No federation-lifecycle resource matches the request path.",
        "NOT_FOUND",
    ),
    405: OegProblem(
        405,
        "urn:oop:ewbi:error:method-not-allowed",
        "Method not allowed",
        "The request method is not supported on this path.",
        "METHOD_NOT_ALLOWED",
    ),
    500: INTERNAL_ERROR,
}


def is_federation_path(path: str) -> bool:
    return path == BASE_PATH or path.startswith(f"{BASE_PATH}/")


def problem_for_status(status: int) -> OegProblem:
    """The template for a framework-level error with this status.

    Statuses without a dedicated template get a generic one built from the HTTP reason
    phrase, so a new status can never fall back to exception text.
    """
    if status in _BY_STATUS:
        return _BY_STATUS[status]
    try:
        phrase = HTTPStatus(status).phrase
    except ValueError:
        return INTERNAL_ERROR._replace(status=status)
    slug = phrase.lower().replace(" ", "-").replace("'", "")
    return OegProblem(
        status,
        f"urn:oop:ewbi:error:{slug}",
        phrase,
        "The request could not be processed.",
        slug.replace("-", "_").upper(),
    )


def problem_response(
    problem: OegProblem,
    invalid_params: list[InvalidParam] | None = None,
    headers: Mapping[str, str] | None = None,
) -> Response:
    body = ProblemDetails(**problem._asdict(), invalidParams=invalid_params)
    return Response(
        content=body.model_dump_json(exclude_none=True),
        status_code=problem.status,
        media_type="application/problem+json",
        headers=headers,
    )


# FastAPI prefixes request-parameter errors with where the value came from.
_REQUEST_LOCATIONS = {"path", "query", "header", "cookie", "body"}


def invalid_params(errors: Iterable[Mapping[str, Any]]) -> list[InvalidParam]:
    """One ``invalidParams`` entry per Pydantic error: field path plus message only.

    ``loc`` becomes a dotted path with list indices in brackets (e.g.
    ``origOPMobileNetworkCodes.mncs[0]``); an error on the body as a whole (empty
    ``loc``) is reported as ``body``. A leading FastAPI location (``path``,
    ``query``, ...) is dropped, so a bad path segment is reported by its name alone
    (e.g. ``partnerOpId``). The rest of the Pydantic error (``input``, ``ctx``,
    ``url``, ``type``) is deliberately dropped.
    """
    params = []
    for error in errors:
        loc: Sequence[str | int] = error["loc"]
        if len(loc) > 1 and loc[0] in _REQUEST_LOCATIONS:
            loc = loc[1:]
        path = ""
        for part in loc:
            path += f"[{part}]" if isinstance(part, int) else f".{part}"
        params.append(InvalidParam(param=path.lstrip(".") or "body", reason=error["msg"]))
    return params
+106 −87

File changed.

Preview size limit exceeded, changes collapsed.

+1 −5
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/schemas.py: 1 added line, 5 removed lines.
Original line number Diff line number Diff line
@@ -18,7 +18,7 @@ from datetime import datetime
from enum import StrEnum
from typing import Annotated, Optional

from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
from pydantic import BaseModel, Field, StringConstraints, model_validator

# --- Scalar types (OpenAPI: string/integer with pattern or format) -------------

@@ -155,8 +155,6 @@ class FederationRequestData(BaseModel):
    ``partnerCallbackCredentials`` no longer exists as a field.
    """

    model_config = ConfigDict(extra="forbid")

    initialDate: datetime
    partnerStatusLink: Uri
    origOPFederationId: Optional[FederationIdentifier] = None
@@ -357,8 +355,6 @@ class ZoneRegisteredData(BaseModel):
class ZoneRegistrationRequestData(BaseModel):
    """Body of ``ZoneSubscribe``."""

    model_config = ConfigDict(extra="forbid")

    acceptedAvailabilityZones: Annotated[list[ZoneIdentifier], Field(min_length=1)]
    availZoneNotifLink: Uri

+301 −15

File changed.

Preview size limit exceeded, changes collapsed.