Commit 16c73395 authored by George Papathanail's avatar George Papathanail
Browse files

fix(fm): give OEG-authored ProblemDetails the full §K.6a field set

parent d568f9aa
Loading
Loading
Loading
Loading
Loading
+1 −1
Changes for pyproject.toml: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -19,7 +19,7 @@ dependencies = [
    "fastapi[standard]>=0.135.1",
    "nats-py>=2.10.0",
    "python-dotenv>=1.2.2",
    "sqlalchemy>=2.0.48",
    "sqlalchemy[asyncio]>=2.0.48",
    "structlog>=25.5.0",
]

+73 −20
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/router.py: 73 added lines, 20 removed lines.
Original line number Diff line number Diff line
@@ -26,7 +26,7 @@ This module exposes the ``FederationManagement`` tag plus ``zone_subscribe``,
"""

import json
from typing import Annotated, Any
from typing import Annotated, Any, NamedTuple

from fastapi import APIRouter, Depends, Query, Request, Response
from pydantic import BaseModel, ValidationError
@@ -39,6 +39,7 @@ from open_exposure_gateway.api.gsma.federation_manager.v1_4_0.schemas import (
    FederationDetails,
    FederationRequestData,
    FederationResponseData,
    InvalidParam,
    PartnerOpId,
    ProblemDetails,
    ZoneIdentifier,
@@ -78,15 +79,76 @@ def _responses(*codes: int) -> dict[int | str, dict[str, Any]]:
    return {code: _ERROR_RESPONSES[code] for code in codes}


def _problem_response(status_code: int, title: str, detail: str) -> Response:
    problem = ProblemDetails(title=title, detail=detail)
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",
)
_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",
)


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


def _invalid_params(exc: ValidationError) -> 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``. The rest of the Pydantic error (``input``,
    ``ctx``, ``url``, ``type``) is deliberately dropped.
    """
    params = []
    for error in exc.errors():
        path = ""
        for part in error["loc"]:
            path += f"[{part}]" if isinstance(part, int) else f".{part}"
        params.append(InvalidParam(param=path.lstrip(".") or "body", reason=error["msg"]))
    return params


def _is_valid_json(content: bytes) -> bool:
    try:
        json.loads(content)
@@ -123,18 +185,12 @@ async def _relay(
        try:
            parsed = json.loads(body)
        except ValueError:
            return _problem_response(
                400, "Malformed request body", "Request body is not valid JSON"
            )
            return _problem_response(_MALFORMED_BODY)
        if request_schema is not None:
            try:
                request_schema.model_validate(parsed)
            except ValidationError as exc:
                return _problem_response(
                    400,
                    "Request body does not match the expected schema",
                    str(exc.errors())[:512],
                )
                return _problem_response(_INVALID_BODY, _invalid_params(exc))

    try:
        fm_response = await fm_client.relay(
@@ -145,15 +201,12 @@ async def _relay(
            x_correlator=x_correlator,
            params=tuple(request.query_params.multi_items()),
        )
    except FmUnavailableError as exc:
        return _problem_response(503, "Federation Manager unavailable", str(exc))
    except FmUnavailableError:
        # FmClient has already logged the underlying cause.
        return _problem_response(_UPSTREAM_UNAVAILABLE)

    if fm_response.content and not _is_valid_json(fm_response.content):
        return _problem_response(
            502,
            "Federation Manager returned an unusable response",
            "Response body is not valid JSON",
        )
        return _problem_response(_UPSTREAM_BAD_RESPONSE)

    return Response(
        content=fm_response.content,
+7 −1
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/schemas.py: 7 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -134,9 +134,15 @@ class InvalidParam(BaseModel):


class ProblemDetails(BaseModel):
    """RFC 7807 error body used by every Federation Manager error response."""
    """RFC 7807 error body used by every Federation Manager error response.

    ``type`` and ``status`` aren't in OPG.04's ``ProblemDetails`` but are allowed as
    additive RFC 7807 fields (FM §L.5); OEG-authored errors always set them (§K.6a).
    """

    type: Optional[str] = None
    title: Optional[str] = None
    status: Optional[int] = None
    detail: Optional[str] = None
    cause: Optional[str] = None
    invalidParams: Optional[Annotated[list[InvalidParam], Field(min_length=1)]] = None
+69 −4
Changes for tests/unit/test_federation_manager_endpoints.py: 69 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -181,7 +181,13 @@ def test_fm_unavailable_returns_problem_details_503(

    assert response.status_code == 503
    assert response.headers["content-type"] == "application/problem+json"
    assert response.json()["title"] == "Federation Manager unavailable"
    assert response.json() == {
        "type": "urn:oop:ewbi:error:upstream-unavailable",
        "title": "Federation Manager unavailable",
        "status": 503,
        "detail": "The federation-lifecycle request could not be forwarded to FM.",
        "cause": "UPSTREAM_UNAVAILABLE",
    }


def test_malformed_request_body_returns_problem_details_400(
@@ -195,7 +201,13 @@ def test_malformed_request_body_returns_problem_details_400(

    assert response.status_code == 400
    assert response.headers["content-type"] == "application/problem+json"
    assert response.json()["title"] == "Malformed request body"
    assert response.json() == {
        "type": "urn:oop:ewbi:error:malformed-request-body",
        "title": "Malformed request body",
        "status": 400,
        "detail": "Request body is not valid JSON.",
        "cause": "MALFORMED_REQUEST_BODY",
    }
    assert fake_fm_client.calls == []


@@ -211,7 +223,53 @@ def test_wrong_shape_request_body_returns_problem_details_400(

    assert response.status_code == 400
    assert response.headers["content-type"] == "application/problem+json"
    assert response.json()["title"] == "Request body does not match the expected schema"
    assert response.json() == {
        "type": "urn:oop:ewbi:error:invalid-request-body",
        "title": "Request body does not match the expected schema",
        "status": 400,
        "detail": "One or more request body fields are invalid; see invalidParams.",
        "cause": "INVALID_REQUEST_BODY",
        "invalidParams": [
            {"param": "initialDate", "reason": "Field required"},
            {"param": "partnerStatusLink", "reason": "Field required"},
            {"param": "a", "reason": "Extra inputs are not permitted"},
        ],
    }
    assert fake_fm_client.calls == []


def test_wrong_shape_nested_field_reports_its_path_in_invalid_params(
    client: TestClient, fake_fm_client: _FakeFmClient
) -> None:
    """A nested list item's path uses dots for fields and brackets for indices."""
    response = client.post(
        f"{BASE_PATH}{_FEDERATIONS}",
        content=(
            b'{"initialDate": "2026-09-04T12:00:00Z", '
            b'"partnerStatusLink": "https://orig-op.example.com/status", '
            b'"origOPMobileNetworkCodes": {"mcc": "202", "mncs": ["01", "x"]}}'
        ),
        headers={"content-type": "application/json"},
    )

    assert response.status_code == 400
    [invalid_param] = response.json()["invalidParams"]
    assert invalid_param["param"] == "origOPMobileNetworkCodes.mncs[1]"
    assert fake_fm_client.calls == []


def test_non_object_request_body_reports_body_in_invalid_params(
    client: TestClient, fake_fm_client: _FakeFmClient
) -> None:
    response = client.post(
        f"{BASE_PATH}{_FEDERATIONS}",
        content=b"[1, 2]",
        headers={"content-type": "application/json"},
    )

    assert response.status_code == 400
    [invalid_param] = response.json()["invalidParams"]
    assert invalid_param["param"] == "body"
    assert fake_fm_client.calls == []


@@ -226,6 +284,7 @@ def test_wrong_shape_zone_subscribe_body_returns_problem_details_400(
    )

    assert response.status_code == 400
    assert response.json()["invalidParams"][0]["param"] == "acceptedAvailabilityZones"
    assert fake_fm_client.calls == []


@@ -254,7 +313,13 @@ def test_unparseable_fm_response_returns_problem_details_502(

    assert response.status_code == 502
    assert response.headers["content-type"] == "application/problem+json"
    assert response.json()["title"] == "Federation Manager returned an unusable response"
    assert response.json() == {
        "type": "urn:oop:ewbi:error:upstream-bad-response",
        "title": "Federation Manager returned an unusable response",
        "status": 502,
        "detail": "FM's response could not be relayed.",
        "cause": "UPSTREAM_BAD_RESPONSE",
    }


def test_openapi_exposes_federation_management_operations() -> None: