Merge feat/api-endpoint: FastAPI surface with NDJSON streaming and CORS for Vite

This commit is contained in:
Tyler
2026-06-19 16:41:21 -06:00
5 changed files with 473 additions and 2 deletions
+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"]