feat(backend): FastAPI endpoint with NDJSON streaming and CORS for Vite frontend

This commit is contained in:
Tyler
2026-06-19 16:35:53 -06:00
parent ab4d8c318e
commit 07a8fbb720
5 changed files with 473 additions and 2 deletions
+87 -1
View File
@@ -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`
+4
View File
@@ -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]
+24 -1
View File
@@ -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()
+204
View File
@@ -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"]
+154
View File
@@ -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()