feat(backend): FastAPI endpoint with NDJSON streaming and CORS for Vite frontend
This commit is contained in:
+87
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Python module + CLI for parsing X12 837P professional claim files into one
|
||||
validated JSON per claim. Output is consumed by the Cyclone Vite/React
|
||||
frontend (later via a FastAPI service).
|
||||
frontend via a FastAPI service (see **REST API** below).
|
||||
|
||||
## Install
|
||||
|
||||
@@ -62,6 +62,92 @@ pytest -v
|
||||
The CLI smoke test against `docs/prodfiles/837p-from-axiscare/*.txt` is
|
||||
auto-skipped if no production files are present.
|
||||
|
||||
## REST API
|
||||
|
||||
The Cyclone Vite/React frontend (default dev origin `http://localhost:5173`)
|
||||
talks to the parser over HTTP. The API module is `cyclone.api:app`.
|
||||
|
||||
### Run the server
|
||||
|
||||
```bash
|
||||
uvicorn cyclone.api:app --reload --port 8000
|
||||
# or, equivalently:
|
||||
python -m cyclone serve
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| ------ | ---------------- | -------------------------------------------------- |
|
||||
| GET | `/api/health` | Liveness probe: `{"status": "ok", "version": ...}` |
|
||||
| POST | `/api/parse-837` | Upload an X12 file, get parsed claims back |
|
||||
|
||||
`POST /api/parse-837` accepts `multipart/form-data` with a single `file`
|
||||
field. Optional query parameters:
|
||||
|
||||
- `payer` — `co_medicaid` (default) or `generic_837p`
|
||||
- `include_raw_segments` — `true` (default) or `false`
|
||||
- `strict` — `false` (default) or `true` (promote warnings to errors)
|
||||
|
||||
The response shape depends on the request's `Accept` header:
|
||||
|
||||
- `Accept: application/json` → a single `ParseResult`-shaped JSON object
|
||||
(envelope, claims, summary).
|
||||
- No `Accept` header (or anything that isn't `application/json`) →
|
||||
`application/x-ndjson` streaming, one JSON object per line in this order:
|
||||
`{"type": "envelope", "data": {...}}`, one `{"type": "claim", "data": {...}}`
|
||||
per claim, then `{"type": "summary", "data": {...}}`.
|
||||
|
||||
Error responses are JSON of the form `{"error": "...", "detail": "..."}`:
|
||||
|
||||
- `400` — file-level parse error (`CycloneParseError`) or unknown `payer`
|
||||
- `422` — at least one claim failed validation (body still includes the
|
||||
full `ParseResult` so the client can render the errors)
|
||||
- `500` — unexpected internal error
|
||||
|
||||
CORS is configured to allow `http://localhost:5173` with `GET` and `POST`
|
||||
and any headers.
|
||||
|
||||
### Example: upload + JSON response
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/parse-837 \
|
||||
-F "file=@./samples/co_837p.txt" \
|
||||
-H "Accept: application/json"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"envelope": { "sender_id": "...", "receiver_id": "...", ... },
|
||||
"claims": [ { "claim_id": "C1", ... }, { "claim_id": "C2", ... } ],
|
||||
"summary": { "total_claims": 2, "passed": 2, "failed": 0, ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Example: stream NDJSON from the browser
|
||||
|
||||
```js
|
||||
const resp = await fetch("http://localhost:8000/api/parse-837", {
|
||||
method: "POST",
|
||||
body: formData, // FormData with a "file" entry
|
||||
});
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
for (const line of buffer.split("\n")) {
|
||||
if (!line) continue;
|
||||
const evt = JSON.parse(line);
|
||||
if (evt.type === "claim") renderClaim(evt.data);
|
||||
else if (evt.type === "summary") showSummary(evt.data);
|
||||
}
|
||||
buffer = buffer.slice(buffer.lastIndexOf("\n") + 1);
|
||||
}
|
||||
```
|
||||
|
||||
## Spec & plan
|
||||
|
||||
- Design: `docs/superpowers/specs/2026-06-19-cyclone-837p-parser-design.md`
|
||||
|
||||
@@ -10,12 +10,16 @@ requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pydantic>=2.6,<3",
|
||||
"click>=8.1,<9",
|
||||
"fastapi>=0.110,<1",
|
||||
"uvicorn[standard]>=0.27,<1",
|
||||
"python-multipart>=0.0.9,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=4.1",
|
||||
"httpx>=0.27,<1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
from cyclone.cli import main
|
||||
"""Entry point for ``python -m cyclone``.
|
||||
|
||||
* ``python -m cyclone`` (no args) — Click CLI (``cli.main``)
|
||||
* ``python -m cyclone serve`` — start the FastAPI app on port 8000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
||||
# Strip the "serve" subcommand so uvicorn doesn't try to interpret
|
||||
# any additional flags we might forward in the future.
|
||||
sys.argv = [sys.argv[0], "cyclone.api:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
import uvicorn
|
||||
|
||||
uvicorn.main()
|
||||
return
|
||||
from cyclone.cli import main as cli_main
|
||||
|
||||
cli_main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""FastAPI HTTP surface for the Cyclone 837P parser.
|
||||
|
||||
The frontend (Vite/React on http://localhost:5173) uploads an X12 file via
|
||||
``POST /api/parse-837`` and receives either:
|
||||
|
||||
* a single ``ParseResult`` JSON object when it sends ``Accept: application/json``,
|
||||
or
|
||||
* a stream of newline-delimited JSON objects (``application/x-ndjson``) — one
|
||||
envelope, one line per claim, then one summary — so the UI can render
|
||||
claims incrementally as they're produced.
|
||||
|
||||
CORS is configured to allow the Vite dev origin (``http://localhost:5173``)
|
||||
plus GET/POST with any header.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Iterator
|
||||
|
||||
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cyclone import __version__
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
from cyclone.parsers.models import ClaimOutput, ParseResult
|
||||
from cyclone.parsers.payer import PayerConfig
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Mirror cli._PAYER_FACTORIES. Kept local so the API doesn't depend on the
|
||||
# Click-based CLI module.
|
||||
PAYER_FACTORIES: dict[str, Any] = {
|
||||
"co_medicaid": PayerConfig.co_medicaid,
|
||||
"generic_837p": PayerConfig.generic_837p,
|
||||
}
|
||||
|
||||
VITE_DEV_ORIGIN = "http://localhost:5173"
|
||||
|
||||
app = FastAPI(
|
||||
title="Cyclone 837P Parser API",
|
||||
version=__version__,
|
||||
description="Upload X12 837P files and receive parsed claims as JSON or NDJSON.",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[VITE_DEV_ORIGIN],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def _resolve_payer(name: str) -> PayerConfig:
|
||||
if name not in PAYER_FACTORIES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "Unknown payer",
|
||||
"detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES)}",
|
||||
},
|
||||
)
|
||||
return PAYER_FACTORIES[name]()
|
||||
|
||||
|
||||
def _strict_rewrite(result: ParseResult) -> ParseResult:
|
||||
"""Promote warnings to errors (mirrors the CLI's --strict)."""
|
||||
claims: list[ClaimOutput] = []
|
||||
for claim in result.claims:
|
||||
promoted = [
|
||||
issue.model_copy(update={"severity": "error"})
|
||||
for issue in claim.validation.warnings
|
||||
]
|
||||
new_errors = claim.validation.errors + promoted
|
||||
claims.append(
|
||||
claim.model_copy(
|
||||
update={
|
||||
"validation": claim.validation.model_copy(
|
||||
update={"errors": new_errors, "passed": not new_errors}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
passed = sum(1 for c in claims if c.validation.passed)
|
||||
failed = len(claims) - passed
|
||||
summary = result.summary.model_copy(
|
||||
update={
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"failed_claim_ids": [c.claim_id for c in claims if not c.validation.passed],
|
||||
}
|
||||
)
|
||||
return result.model_copy(update={"claims": claims, "summary": summary})
|
||||
|
||||
|
||||
def _drop_raw_segments(result: ParseResult) -> ParseResult:
|
||||
"""Return a copy of ``result`` with ``raw_segments`` cleared on every claim."""
|
||||
claims = [c.model_copy(update={"raw_segments": []}) for c in result.claims]
|
||||
return result.model_copy(update={"claims": claims})
|
||||
|
||||
|
||||
def _client_wants_json(request: Request) -> bool:
|
||||
"""Content negotiation: prefer ``application/json`` when the client asks for it.
|
||||
|
||||
NDJSON is the default for browser uploads that don't set ``Accept``. The
|
||||
frontend opts into JSON via ``Accept: application/json``.
|
||||
"""
|
||||
accept = request.headers.get("accept", "")
|
||||
# If the client mentions JSON at all (and isn't asking for NDJSON
|
||||
# specifically) treat it as a single-object request. The browser default
|
||||
# ``*/*`` falls through to NDJSON.
|
||||
if "application/json" in accept and "application/x-ndjson" not in accept:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_claim_validation_errors(result: ParseResult) -> bool:
|
||||
return any(not c.validation.passed for c in result.claims)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok", "version": __version__}
|
||||
|
||||
|
||||
@app.post("/api/parse-837")
|
||||
async def parse_837(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
payer: str = Query("co_medicaid"),
|
||||
include_raw_segments: bool = Query(True),
|
||||
strict: bool = Query(False),
|
||||
) -> Any:
|
||||
raw = await file.read()
|
||||
if not raw:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||||
)
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "Encoding error", "detail": str(exc)},
|
||||
)
|
||||
|
||||
config = _resolve_payer(payer)
|
||||
|
||||
try:
|
||||
result = parse(text, config, input_file=file.filename or "")
|
||||
except CycloneParseError as exc:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "Parse error", "detail": str(exc)},
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - safety net
|
||||
log.exception("Unexpected parser failure")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "Internal server error", "detail": str(exc)},
|
||||
)
|
||||
|
||||
if strict:
|
||||
result = _strict_rewrite(result)
|
||||
if not include_raw_segments:
|
||||
result = _drop_raw_segments(result)
|
||||
|
||||
if _has_claim_validation_errors(result):
|
||||
# Per spec: 422 when claims failed validation.
|
||||
# Body still includes the full ParseResult so the client can show errors.
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=json.loads(result.model_dump_json()),
|
||||
)
|
||||
|
||||
if _client_wants_json(request):
|
||||
return JSONResponse(content=json.loads(result.model_dump_json()))
|
||||
|
||||
# Default: NDJSON stream.
|
||||
return StreamingResponse(
|
||||
_ndjson_stream(result),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
|
||||
def _ndjson_stream(result: ParseResult) -> Iterator[bytes]:
|
||||
"""Yield one JSON object per line: envelope → claims → summary."""
|
||||
envelope_obj = (
|
||||
result.envelope.model_dump() if result.envelope is not None else None
|
||||
)
|
||||
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
|
||||
for claim in result.claims:
|
||||
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
||||
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
__all__ = ["app"]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for the FastAPI surface in ``cyclone.api``.
|
||||
|
||||
All tests use ``fastapi.testclient.TestClient`` — no real network or uvicorn
|
||||
process is started. The fixture file is the same one used by the parser's
|
||||
own end-to-end test (``co_medicaid_837p.txt`` → 2 claims, both pass).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone.api import app
|
||||
from cyclone import __version__
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Health
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_health_endpoint(client: TestClient):
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body == {"status": "ok", "version": __version__}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# JSON response path
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_837_endpoint_returns_json(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-837",
|
||||
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "envelope" in body
|
||||
assert body["envelope"] is not None
|
||||
assert "claims" in body and len(body["claims"]) == 2
|
||||
assert "summary" in body
|
||||
assert body["summary"]["total_claims"] == 2
|
||||
assert body["summary"]["passed"] == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# NDJSON streaming path
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_837_endpoint_streams_ndjson(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-837",
|
||||
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
||||
|
||||
# Consume line-by-line (this is exactly what the React frontend will do).
|
||||
lines = list(resp.iter_lines())
|
||||
# 1 envelope + 2 claims + 1 summary
|
||||
assert len(lines) == 4
|
||||
|
||||
parsed = [json.loads(line) for line in lines]
|
||||
assert parsed[0]["type"] == "envelope"
|
||||
assert parsed[0]["data"] is not None
|
||||
assert parsed[1]["type"] == "claim"
|
||||
assert parsed[2]["type"] == "claim"
|
||||
assert parsed[3]["type"] == "summary"
|
||||
|
||||
# When include_raw_segments defaults to True, each claim carries raw segments.
|
||||
for obj in parsed[1:3]:
|
||||
assert "raw_segments" in obj["data"]
|
||||
assert isinstance(obj["data"]["raw_segments"], list)
|
||||
|
||||
# Summary numbers match the JSON path.
|
||||
assert parsed[3]["data"]["total_claims"] == 2
|
||||
assert parsed[3]["data"]["passed"] == 2
|
||||
|
||||
|
||||
def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-837?include_raw_segments=false",
|
||||
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
||||
|
||||
claims = [json.loads(line) for line in resp.iter_lines() if json.loads(line)["type"] == "claim"]
|
||||
assert len(claims) == 2
|
||||
for c in claims:
|
||||
assert c["data"]["raw_segments"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Validation / error paths
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_837_endpoint_rejects_missing_file(client: TestClient):
|
||||
# FastAPI's `File(...)` (no default) → 422 Unprocessable Entity.
|
||||
resp = client.post("/api/parse-837")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_parse_837_endpoint_handles_payer_query_param(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
for payer in ("co_medicaid", "generic_837p"):
|
||||
resp = client.post(
|
||||
f"/api/parse-837?payer={payer}",
|
||||
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200, (payer, resp.text)
|
||||
body = resp.json()
|
||||
assert body["summary"]["passed"] == 2
|
||||
assert body["summary"]["total_claims"] == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CORS
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_cors_headers_present(client: TestClient):
|
||||
# Simulate a preflight from the Vite dev origin.
|
||||
resp = client.options(
|
||||
"/api/parse-837",
|
||||
headers={
|
||||
"Origin": "http://localhost:5173",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||
assert "POST" in resp.headers.get("access-control-allow-methods", "").upper()
|
||||
Reference in New Issue
Block a user