feat(sp40): Edifabric HTTP client (read + validate + composed)

Two-step wrapper around https://api.edination.com/v2/x12/read and
/x12/validate. Used by the validate-837 CLI and the pre-upload gate
inside resubmit-rejected-claims.

cyclone.edifabric public surface:
- read_interchange(edi_bytes) -> X12Interchange
- validate_interchange(x12_json) -> OperationResult
- validate_edi(edi_bytes) -> OperationResult
- EdifabricError(status, body) for non-2xx responses

API key resolved lazily via cyclone.secrets.get_secret('edifabric.api_key')
which maps to CYCLONE_EDIFABRIC_API_KEY env var or keychain. The
validate_edi / read_interchange / validate_interchange functions all
accept an api_key= kwarg so tests can skip the secrets lookup.

Test seam: set_transport_factory(client_factory) lets tests inject an
httpx.Client backed by httpx.MockTransport — no live HTTP. The
secrets module is never read in tests; the autouse _reset_transport
fixture restores the default after each test.

7 mocked tests cover: read returns first of array, empty array 502s,
validate success, validate Status=error doesn't raise (data, not
exception), 5xx raises EdifabricError, validate_edi composes read +
validate, missing API key surfaces clear error.
This commit is contained in:
Nora
2026-07-07 18:07:53 -06:00
parent c5604fdfa0
commit b237b9cfc4
2 changed files with 404 additions and 0 deletions
+216
View File
@@ -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: <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: <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 <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 <file>`` 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"<empty {resp.status_code} response>"
+188
View File
@@ -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)