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:
@@ -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>"
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user