Loading src/open_exposure_gateway/adapters/http/fm_client.py 0 → 100644 +64 −0 Original line number Diff line number Diff line import httpx import structlog from open_exposure_gateway.core.config import get_settings logger = structlog.get_logger(__name__) # fm/requirements-and-design.md §L.3b says FM exposes "matching internal endpoints" # under /internal/federation/ without naming them per operation. Mirroring the # operator-facing suffix 1:1 is the reading agreed on until FM's side is built and # confirms the actual paths. _INTERNAL_PREFIX = "/internal/federation" class FmUnavailableError(Exception): """FM did not answer the internal call (connection failure or timeout).""" class FmClient: def __init__(self) -> None: settings = get_settings() self.base_url = str(settings.fm_settings.base_url).rstrip("/") self.timeout = settings.fm_settings.timeout async def relay( self, method: str, path: str, content: bytes | None, content_type: str | None, x_correlator: str | None, ) -> httpx.Response: """Forward one federation-lifecycle operation to FM, unaltered. `path` is the operator-facing suffix after ``/operatorplatform/federation/v1`` (e.g. ``/partner``, ``/{federationContextId}/zones/{zoneId}``). The request body is forwarded as the raw bytes the caller sent -- no parsing, no reshaping -- and the caller relays the returned response body and status the same way. """ url = f"{self.base_url}{_INTERNAL_PREFIX}{path}" headers = {"X-Correlator": x_correlator} if x_correlator else None if content_type: headers = {**(headers or {}), "Content-Type": content_type} log = logger.bind(method=method, url=url, x_correlator=x_correlator) try: async with httpx.AsyncClient(timeout=self.timeout) as client: return await client.request( method=method, url=url, content=content, headers=headers, ) except httpx.TimeoutException as exc: log.exception("FM request timed out") raise FmUnavailableError("FM request timed out") from exc except httpx.ConnectError as exc: log.exception("FM connection failed") raise FmUnavailableError("Could not connect to FM") from exc except httpx.RequestError as exc: log.exception("FM request error") raise FmUnavailableError("FM request failed") from exc tests/unit/test_fm_client.py 0 → 100644 +121 −0 Original line number Diff line number Diff line """FmClient.relay -- byte-for-byte forwarding and the FM-unreachable failure mode. FmClient does no parsing or domain mapping, so what's tested here is that the request/response pass through untouched and that connection failures surface as FmUnavailableError rather than an OEG CAMARA exception. """ from collections.abc import Callable from typing import Any import httpx import pytest from open_exposure_gateway.adapters.http.fm_client import FmClient, FmUnavailableError HttpHandler = Callable[[httpx.Request], httpx.Response] def _client(monkeypatch: pytest.MonkeyPatch, handler: HttpHandler) -> FmClient: transport = httpx.MockTransport(handler) class _PatchedAsyncClient(httpx.AsyncClient): def __init__(self, *args: Any, **kwargs: Any) -> None: kwargs["transport"] = transport super().__init__(*args, **kwargs) monkeypatch.setattr(httpx, "AsyncClient", _PatchedAsyncClient) client = FmClient.__new__(FmClient) client.base_url = "http://fm:8082" client.timeout = 1.0 return client async def test_relay_forwards_method_url_body_and_correlator( monkeypatch: pytest.MonkeyPatch, ) -> None: seen: dict[str, Any] = {} def handler(request: httpx.Request) -> httpx.Response: seen["method"] = request.method seen["url"] = str(request.url) seen["content"] = request.content seen["content_type"] = request.headers.get("content-type") seen["x_correlator"] = request.headers.get("x-correlator") return httpx.Response(200, content=b'{"federationContextId": "ctx-1"}') client = _client(monkeypatch, handler) response = await client.relay( "POST", "/partner", content=b'{"origOPFederationId": "op-1"}', content_type="application/json", x_correlator="corr-1", ) assert seen["method"] == "POST" assert seen["url"] == "http://fm:8082/internal/federation/partner" assert seen["content"] == b'{"origOPFederationId": "op-1"}' assert seen["content_type"] == "application/json" assert seen["x_correlator"] == "corr-1" assert response.status_code == 200 assert response.content == b'{"federationContextId": "ctx-1"}' async def test_relay_returns_fm_error_response_untouched(monkeypatch: pytest.MonkeyPatch) -> None: client = _client( monkeypatch, lambda request: httpx.Response( 409, content=b'{"status": 409, "detail": "federation already exists"}', headers={"content-type": "application/problem+json"}, ), ) response = await client.relay( "POST", "/partner", content=b"{}", content_type="application/json", x_correlator=None ) assert response.status_code == 409 assert response.headers["content-type"] == "application/problem+json" assert response.content == b'{"status": 409, "detail": "federation already exists"}' async def test_relay_without_correlator_or_content_type_omits_headers( monkeypatch: pytest.MonkeyPatch, ) -> None: seen: dict[str, Any] = {} def handler(request: httpx.Request) -> httpx.Response: seen["x_correlator"] = request.headers.get("x-correlator") return httpx.Response(200) client = _client(monkeypatch, handler) await client.relay("GET", "/fed-context-id", content=None, content_type=None, x_correlator=None) assert seen["x_correlator"] is None async def test_relay_timeout_raises_fm_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: def handler(request: httpx.Request) -> httpx.Response: raise httpx.ReadTimeout("timed out") client = _client(monkeypatch, handler) with pytest.raises(FmUnavailableError): await client.relay( "GET", "/fed-context-id", content=None, content_type=None, x_correlator=None ) async def test_relay_connect_error_raises_fm_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: def handler(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("connection refused") client = _client(monkeypatch, handler) with pytest.raises(FmUnavailableError): await client.relay( "GET", "/fed-context-id", content=None, content_type=None, x_correlator=None ) Loading
src/open_exposure_gateway/adapters/http/fm_client.py 0 → 100644 +64 −0 Original line number Diff line number Diff line import httpx import structlog from open_exposure_gateway.core.config import get_settings logger = structlog.get_logger(__name__) # fm/requirements-and-design.md §L.3b says FM exposes "matching internal endpoints" # under /internal/federation/ without naming them per operation. Mirroring the # operator-facing suffix 1:1 is the reading agreed on until FM's side is built and # confirms the actual paths. _INTERNAL_PREFIX = "/internal/federation" class FmUnavailableError(Exception): """FM did not answer the internal call (connection failure or timeout).""" class FmClient: def __init__(self) -> None: settings = get_settings() self.base_url = str(settings.fm_settings.base_url).rstrip("/") self.timeout = settings.fm_settings.timeout async def relay( self, method: str, path: str, content: bytes | None, content_type: str | None, x_correlator: str | None, ) -> httpx.Response: """Forward one federation-lifecycle operation to FM, unaltered. `path` is the operator-facing suffix after ``/operatorplatform/federation/v1`` (e.g. ``/partner``, ``/{federationContextId}/zones/{zoneId}``). The request body is forwarded as the raw bytes the caller sent -- no parsing, no reshaping -- and the caller relays the returned response body and status the same way. """ url = f"{self.base_url}{_INTERNAL_PREFIX}{path}" headers = {"X-Correlator": x_correlator} if x_correlator else None if content_type: headers = {**(headers or {}), "Content-Type": content_type} log = logger.bind(method=method, url=url, x_correlator=x_correlator) try: async with httpx.AsyncClient(timeout=self.timeout) as client: return await client.request( method=method, url=url, content=content, headers=headers, ) except httpx.TimeoutException as exc: log.exception("FM request timed out") raise FmUnavailableError("FM request timed out") from exc except httpx.ConnectError as exc: log.exception("FM connection failed") raise FmUnavailableError("Could not connect to FM") from exc except httpx.RequestError as exc: log.exception("FM request error") raise FmUnavailableError("FM request failed") from exc
tests/unit/test_fm_client.py 0 → 100644 +121 −0 Original line number Diff line number Diff line """FmClient.relay -- byte-for-byte forwarding and the FM-unreachable failure mode. FmClient does no parsing or domain mapping, so what's tested here is that the request/response pass through untouched and that connection failures surface as FmUnavailableError rather than an OEG CAMARA exception. """ from collections.abc import Callable from typing import Any import httpx import pytest from open_exposure_gateway.adapters.http.fm_client import FmClient, FmUnavailableError HttpHandler = Callable[[httpx.Request], httpx.Response] def _client(monkeypatch: pytest.MonkeyPatch, handler: HttpHandler) -> FmClient: transport = httpx.MockTransport(handler) class _PatchedAsyncClient(httpx.AsyncClient): def __init__(self, *args: Any, **kwargs: Any) -> None: kwargs["transport"] = transport super().__init__(*args, **kwargs) monkeypatch.setattr(httpx, "AsyncClient", _PatchedAsyncClient) client = FmClient.__new__(FmClient) client.base_url = "http://fm:8082" client.timeout = 1.0 return client async def test_relay_forwards_method_url_body_and_correlator( monkeypatch: pytest.MonkeyPatch, ) -> None: seen: dict[str, Any] = {} def handler(request: httpx.Request) -> httpx.Response: seen["method"] = request.method seen["url"] = str(request.url) seen["content"] = request.content seen["content_type"] = request.headers.get("content-type") seen["x_correlator"] = request.headers.get("x-correlator") return httpx.Response(200, content=b'{"federationContextId": "ctx-1"}') client = _client(monkeypatch, handler) response = await client.relay( "POST", "/partner", content=b'{"origOPFederationId": "op-1"}', content_type="application/json", x_correlator="corr-1", ) assert seen["method"] == "POST" assert seen["url"] == "http://fm:8082/internal/federation/partner" assert seen["content"] == b'{"origOPFederationId": "op-1"}' assert seen["content_type"] == "application/json" assert seen["x_correlator"] == "corr-1" assert response.status_code == 200 assert response.content == b'{"federationContextId": "ctx-1"}' async def test_relay_returns_fm_error_response_untouched(monkeypatch: pytest.MonkeyPatch) -> None: client = _client( monkeypatch, lambda request: httpx.Response( 409, content=b'{"status": 409, "detail": "federation already exists"}', headers={"content-type": "application/problem+json"}, ), ) response = await client.relay( "POST", "/partner", content=b"{}", content_type="application/json", x_correlator=None ) assert response.status_code == 409 assert response.headers["content-type"] == "application/problem+json" assert response.content == b'{"status": 409, "detail": "federation already exists"}' async def test_relay_without_correlator_or_content_type_omits_headers( monkeypatch: pytest.MonkeyPatch, ) -> None: seen: dict[str, Any] = {} def handler(request: httpx.Request) -> httpx.Response: seen["x_correlator"] = request.headers.get("x-correlator") return httpx.Response(200) client = _client(monkeypatch, handler) await client.relay("GET", "/fed-context-id", content=None, content_type=None, x_correlator=None) assert seen["x_correlator"] is None async def test_relay_timeout_raises_fm_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: def handler(request: httpx.Request) -> httpx.Response: raise httpx.ReadTimeout("timed out") client = _client(monkeypatch, handler) with pytest.raises(FmUnavailableError): await client.relay( "GET", "/fed-context-id", content=None, content_type=None, x_correlator=None ) async def test_relay_connect_error_raises_fm_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: def handler(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("connection refused") client = _client(monkeypatch, handler) with pytest.raises(FmUnavailableError): await client.relay( "GET", "/fed-context-id", content=None, content_type=None, x_correlator=None )