Commit 89a0e9cc authored by George Papathanail's avatar George Papathanail
Browse files

feat: add FmCLient

parent ba260cc4
Loading
Loading
Loading
Loading
Loading
+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
+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
        )