diff --git a/backend/README.md b/backend/README.md index 6aaf6e7..6663183 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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` diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a245252..51a5842 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/backend/src/cyclone/__main__.py b/backend/src/cyclone/__main__.py index 6fcbc3a..c0942e8 100644 --- a/backend/src/cyclone/__main__.py +++ b/backend/src/cyclone/__main__.py @@ -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() diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py new file mode 100644 index 0000000..3835168 --- /dev/null +++ b/backend/src/cyclone/api.py @@ -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"] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..4d35267 --- /dev/null +++ b/backend/tests/test_api.py @@ -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()