Loading pyproject.toml +1 −1 Changes for pyproject.toml: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -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", ] Loading src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/router.py +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 Loading @@ -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 Loading @@ -39,6 +39,7 @@ from open_exposure_gateway.api.gsma.federation_manager.v1_4_0.schemas import ( FederationDetails, FederationRequestData, FederationResponseData, InvalidParam, PartnerOpId, ProblemDetails, ZoneIdentifier, Loading Loading @@ -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) Loading Loading @@ -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( Loading @@ -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, Loading src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/schemas.py +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 Loading @@ -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 Loading tests/unit/test_federation_manager_endpoints.py +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 Loading @@ -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( Loading @@ -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 == [] Loading @@ -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 == [] Loading @@ -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 == [] Loading Loading @@ -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: Loading Loading
pyproject.toml +1 −1 Changes for pyproject.toml: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -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", ] Loading
src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/router.py +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 Loading @@ -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 Loading @@ -39,6 +39,7 @@ from open_exposure_gateway.api.gsma.federation_manager.v1_4_0.schemas import ( FederationDetails, FederationRequestData, FederationResponseData, InvalidParam, PartnerOpId, ProblemDetails, ZoneIdentifier, Loading Loading @@ -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) Loading Loading @@ -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( Loading @@ -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, Loading
src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/schemas.py +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 Loading @@ -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 Loading
tests/unit/test_federation_manager_endpoints.py +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 Loading @@ -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( Loading @@ -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 == [] Loading @@ -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 == [] Loading @@ -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 == [] Loading Loading @@ -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: Loading