feat(backend): FastAPI endpoint with NDJSON streaming and CORS for Vite frontend
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user