diff --git a/backend/src/cyclone/edifabric.py b/backend/src/cyclone/edifabric.py new file mode 100644 index 0000000..41727bf --- /dev/null +++ b/backend/src/cyclone/edifabric.py @@ -0,0 +1,216 @@ +"""SP40: thin HTTP client for the EdiNation / Edifabric validation API. + +Wraps the two-step /v2/x12/read (raw EDI → ``X12Interchange`` JSON) and +/v2/x12/validate (``X12Interchange`` JSON → ``OperationResult``) flow. + +Cyclone has no other outbound HTTP today; this is the first such +client. ``httpx`` is already a project dep (used by the test suite) +so we don't introduce a new dependency. + +Public surface: + +- :func:`read_interchange` — POST raw EDI bytes to /x12/read. +- :func:`validate_interchange` — POST ``X12Interchange`` JSON to /x12/validate. +- :func:`validate_edi` — composes the two; this is what the CLI and + pre-upload gate use. +- :class:`EdifabricError` — raised on 4xx/5xx so callers can surface + the Edifabric error verbatim (the body is a dict or string). + +The API key is taken from :func:`cyclone.secrets.get_secret('edifabric.api_key')` +which maps to ``CYCLONE_EDIFABRIC_API_KEY`` env var or macOS Keychain. +Tests inject a canned key (and a mocked transport) via the factory +hook :func:`set_transport_factory` so no live HTTP hits the network. + +The endpoint contract (from the EdiNation API reference, +``https://support.edifabric.com/hc/en-us/sections/360005605638``): + +- ``POST https://api.edination.com/v2/x12/read`` + - Headers: ``Ocp-Apim-Subscription-Key: ``, ``Content-Type: application/octet-stream`` + - Body: raw EDI bytes + - Response (200): JSON array of ``X12Interchange`` objects (cyclone always sends one interchange) +- ``POST https://api.edination.com/v2/x12/validate`` + - Headers: ``Ocp-Apim-Subscription-Key: ``, ``Content-Type: application/json`` + - Body: one ``X12Interchange`` object + - Response (200): ``OperationResult`` (``Status`` ∈ {``"success"``, ``"warning"``, ``"error"``}, ``Details`` array) + +We treat any non-2xx as an :class:`EdifabricError`. The response body +is preserved verbatim so callers can inspect it. +""" +from __future__ import annotations + +import logging +import os +from typing import Any, Callable + +import httpx + +_log = logging.getLogger(__name__) + +_BASE_URL = "https://api.edination.com/v2/x12" +_READ_PATH = "/read" +_VALIDATE_PATH = "/validate" + +# Subscription-key header name (Azure API Management convention). +_SUB_HEADER = "Ocp-Apim-Subscription-Key" + + +class EdifabricError(RuntimeError): + """Raised when the Edifabric API returns a non-2xx response. + + Attributes: + status_code: HTTP status code returned by Edifabric. + body: The response body — usually a dict with ``error`` / + ``message`` keys for 4xx, or a string for 5xx / network + errors. Preserved verbatim so the caller can surface it + to the operator. + """ + + def __init__(self, status_code: int, body: Any) -> None: + self.status_code = status_code + self.body = body + body_repr = repr(body) if not isinstance(body, str) else body + msg = f"Edifabric API returned {status_code}: {body_repr}" + super().__init__(msg) + + +# --- Transport injection (test seam) ----------------------------------- + +# Default transport factory builds a normal httpx.Client. Tests can +# call set_transport_factory() with a callable that returns a Client +# backed by httpx.MockTransport (no live HTTP). +_transport_factory: Callable[[], httpx.Client] = lambda: httpx.Client(timeout=30.0) + + +def set_transport_factory(factory: Callable[[], httpx.Client]) -> None: + """Inject an ``httpx.Client`` factory for tests. + + The factory must return an ``httpx.Client`` whose ``transport`` is + a mock (e.g. ``httpx.MockTransport(handler)``) so no real HTTP is + performed. Returns the previous factory so tests can restore it. + """ + global _transport_factory + prev = _transport_factory + _transport_factory = factory + return prev # type: ignore[return-value] + + +def _reset_transport_factory() -> None: + """Restore the default transport factory (called in test cleanup).""" + global _transport_factory + _transport_factory = lambda: httpx.Client(timeout=30.0) + + +# --- Public surface ---------------------------------------------------- + + +def _get_api_key() -> str: + """Resolve the Edifabric API key. + + Looks up ``CYCLONE_EDIFABRIC_API_KEY`` (or the keychain entry + ``cyclone / edifabric.api_key``). The secrets module is imported + lazily so test setups that mock it can do so before first use. + """ + from cyclone.secrets import get_secret + + key = get_secret("edifabric.api_key") + if not key: + raise EdifabricError( + 0, + "Edifabric API key not configured; set CYCLONE_EDIFABRIC_API_KEY " + "env var or run `cyclone secrets set edifabric.api_key `", + ) + return key + + +def read_interchange(edi_bytes: bytes, *, api_key: str | None = None) -> dict: + """POST raw EDI bytes to /x12/read and return the first X12Interchange. + + The /x12/read endpoint accepts a multi-interchange file and returns + an array. Cyclone only ever sends single interchanges, so we return + the first (and only) element. If the file contains multiple + interchanges, callers should call ``validate_interchange`` on each. + """ + if not isinstance(edi_bytes, (bytes, bytearray)): + raise TypeError( + f"edi_bytes must be bytes, got {type(edi_bytes).__name__}" + ) + key = api_key if api_key is not None else _get_api_key() + headers = { + _SUB_HEADER: key, + "Content-Type": "application/octet-stream", + } + with _transport_factory() as client: + resp = client.post( + f"{_BASE_URL}{_READ_PATH}", + content=bytes(edi_bytes), + headers=headers, + ) + if not (200 <= resp.status_code < 300): + raise EdifabricError(resp.status_code, _safe_body(resp)) + data = resp.json() + if not isinstance(data, list) or not data: + raise EdifabricError( + 502, + f"unexpected /x12/read response shape: expected non-empty list, " + f"got {type(data).__name__} of length {len(data) if hasattr(data, '__len__') else '?'}", + ) + return data[0] + + +def validate_interchange(x12_json: dict, *, api_key: str | None = None) -> dict: + """POST an X12Interchange JSON to /x12/validate and return the OperationResult. + + The OperationResult schema (per the EdiNation docs): + + - ``Status`` — ``"success"`` / ``"warning"`` / ``"error"``. + - ``Details`` — array of ``{Index, SegmentId, Value, Message, Status, ...}``. + - ``LastIndex`` — 1-based index of the last processed segment. + + We do NOT raise on ``Status == "error"`` — the caller decides whether + to fail-closed (the pre-upload gate does; the CLI prints and exits + with the appropriate code). Non-2xx HTTP responses DO raise + :class:`EdifabricError`. + """ + if not isinstance(x12_json, dict): + raise TypeError( + f"x12_json must be a dict, got {type(x12_json).__name__}" + ) + key = api_key if api_key is not None else _get_api_key() + headers = { + _SUB_HEADER: key, + "Content-Type": "application/json", + } + with _transport_factory() as client: + resp = client.post( + f"{_BASE_URL}{_VALIDATE_PATH}", + json=x12_json, + headers=headers, + ) + if not (200 <= resp.status_code < 300): + raise EdifabricError(resp.status_code, _safe_body(resp)) + return resp.json() + + +def validate_edi(edi_bytes: bytes, *, api_key: str | None = None) -> dict: + """Two-step convenience: read → validate. Returns the OperationResult. + + This is what ``cyclone validate-837 `` and the pre-upload + gate in ``resubmit-rejected-claims`` call. Raises + :class:`EdifabricError` on transport / non-2xx errors. The + OperationResult is returned verbatim so the caller can inspect + ``Status`` and ``Details`` themselves. + """ + x12 = read_interchange(edi_bytes, api_key=api_key) + return validate_interchange(x12, api_key=api_key) + + +# --- Internal helpers -------------------------------------------------- + + +def _safe_body(resp: httpx.Response) -> Any: + """Return the response body, preferring JSON when possible.""" + try: + return resp.json() + except Exception: # noqa: BLE001 + text = resp.text + return text if text else f"" \ No newline at end of file diff --git a/backend/tests/test_edifabric.py b/backend/tests/test_edifabric.py new file mode 100644 index 0000000..75f7c7b --- /dev/null +++ b/backend/tests/test_edifabric.py @@ -0,0 +1,188 @@ +"""SP40: tests for the cyclone.edifabric HTTP client. + +All tests use httpx.MockTransport — no live HTTP hits the network. +The API key is supplied directly via the ``api_key=`` kwarg so the +secrets module is never read during tests. +""" +from __future__ import annotations + +import json + +import httpx +import pytest + +from cyclone import edifabric + + +_TEST_KEY = "test-edifabric-key-0123456789abcdef" + + +def _make_client(handler): + """Build an httpx.Client whose transport is the given handler.""" + transport = httpx.MockTransport(handler) + return httpx.Client(transport=transport, timeout=10.0) + + +def _install_factory(handler): + """Swap in the mocked httpx.Client for the duration of a test.""" + return edifabric.set_transport_factory(lambda: _make_client(handler)) + + +@pytest.fixture(autouse=True) +def _reset_transport(): + """Restore the default transport after each test (so a failing test + can't poison the next).""" + yield + edifabric._reset_transport_factory() + + +# --- /x12/read --------------------------------------------------------- + + +def test_read_interchange_returns_first_x12_from_array(): + """The /x12/read endpoint returns a list (multi-interchange file). + Cyclone calls return the first element.""" + x12 = { + "SegmentDelimiter": "~", + "DataElementDelimiter": "*", + "ISA": {"InterchangeControlNumber_13": "000000001"}, + "Groups": [], + "IEATrailers": [], + } + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["Ocp-Apim-Subscription-Key"] == _TEST_KEY + assert request.headers["Content-Type"] == "application/octet-stream" + return httpx.Response(200, json=[x12]) + + _install_factory(handler) + result = edifabric.read_interchange(b"ISA*...~IEA*0*000000001~", api_key=_TEST_KEY) + assert result["ISA"]["InterchangeControlNumber_13"] == "000000001" + + +def test_read_interchange_rejects_empty_response(): + """If /x12/read returns an empty list, surface a 502-style error.""" + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[]) + + _install_factory(handler) + with pytest.raises(edifabric.EdifabricError) as exc_info: + edifabric.read_interchange(b"ISA*...~IEA*0*000000001~", api_key=_TEST_KEY) + assert exc_info.value.status_code == 502 + + +# --- /x12/validate ----------------------------------------------------- + + +def test_validate_interchange_returns_operation_result(): + """A 200 response is returned verbatim — Status + Details.""" + operation_result = { + "Status": "success", + "Details": [], + "LastIndex": 46, + } + x12 = { + "SegmentDelimiter": "~", + "DataElementDelimiter": "*", + "ISA": {"InterchangeControlNumber_13": "000000001"}, + "Groups": [], + "IEATrailers": [], + } + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + assert body["ISA"]["InterchangeControlNumber_13"] == "000000001" + return httpx.Response(200, json=operation_result) + + _install_factory(handler) + result = edifabric.validate_interchange(x12, api_key=_TEST_KEY) + assert result["Status"] == "success" + assert result["Details"] == [] + + +def test_validate_interchange_does_not_raise_on_status_error(): + """OperationResult.Status='error' is data, not an exception — the + caller (the gate / CLI) decides whether to fail-closed.""" + operation_result = { + "Status": "error", + "Details": [ + {"Index": 5, "SegmentId": "PER", "Message": "PER-04 is required", "Status": "error"}, + ], + "LastIndex": 5, + } + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=operation_result) + + _install_factory(handler) + result = edifabric.validate_interchange({"ISA": {}}, api_key=_TEST_KEY) + assert result["Status"] == "error" + assert result["Details"][0]["Message"] == "PER-04 is required" + + +# --- 4xx / 5xx --------------------------------------------------------- + + +def test_validate_interchange_raises_on_5xx(): + """Non-2xx responses raise EdifabricError; the body is preserved.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(503, text="upstream overloaded") + + _install_factory(handler) + with pytest.raises(edifabric.EdifabricError) as exc_info: + edifabric.validate_interchange({"ISA": {}}, api_key=_TEST_KEY) + assert exc_info.value.status_code == 503 + assert "upstream overloaded" in str(exc_info.value.body) + + +# --- validate_edi (composed) ------------------------------------------ + + +def test_validate_edi_composes_read_then_validate(): + """validate_edi should call read first, then validate with the + X12Interchange JSON from read's response.""" + x12 = { + "SegmentDelimiter": "~", + "DataElementDelimiter": "*", + "ISA": {"InterchangeControlNumber_13": "000000099"}, + "Groups": [], + "IEATrailers": [], + } + operation_result = {"Status": "success", "Details": [], "LastIndex": 10} + seen_calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/read"): + seen_calls.append("read") + return httpx.Response(200, json=[x12]) + if request.url.path.endswith("/validate"): + seen_calls.append("validate") + # Verify the validate body is the X12Interchange JSON + body = json.loads(request.content) + assert body["ISA"]["InterchangeControlNumber_13"] == "000000099" + return httpx.Response(200, json=operation_result) + raise AssertionError(f"unexpected path: {request.url.path}") + + _install_factory(handler) + result = edifabric.validate_edi(b"ISA*...~IEA*0*000000099~", api_key=_TEST_KEY) + assert seen_calls == ["read", "validate"] + assert result["Status"] == "success" + + +# --- API key handling -------------------------------------------------- + + +def test_validate_edi_raises_when_api_key_missing(monkeypatch): + """With no key configured anywhere, validate_edi surfaces a clear + error to the operator (not a generic 500).""" + monkeypatch.delenv("CYCLONE_EDIFABRIC_API_KEY", raising=False) + + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("transport should not be called when key is missing") + + _install_factory(handler) + with pytest.raises(edifabric.EdifabricError) as exc_info: + edifabric.validate_edi(b"ISA*...~IEA*0*000000001~") + assert exc_info.value.status_code == 0 + assert "API key not configured" in str(exc_info.value.body) \ No newline at end of file