From a63ba5e88c3365f52e060fc97b3f67f317cc68e1 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 21 Jun 2026 00:49:14 -0600 Subject: [PATCH] refactor(api): split health + acks + ta1_acks routes into FastAPI APIRouters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 (first half) of the architecture satisfaction loop. api.py shrank from 2595 to 2452 lines (-143) by extracting three read-only resource groups into cyclone.api_routers: - health.py: GET /api/health (1 endpoint) - acks.py: GET /api/acks, GET /api/acks/{ack_id} (2 endpoints) - ta1_acks.py: GET /api/ta1-acks, GET /api/ta1-acks/{ack_id} (2 endpoints) Each router owns its endpoint bodies + the small UI-shape helper that goes with them (_ack_to_ui, _ta1_to_ui, _serialize_ta1_from_row). The helpers stay in the router file rather than being shared because each is only used by its own endpoints. api.py now ends the app-wiring section with three include_router() calls. The new package is named cyclone.api_routers (not cyclone.api.routers) to avoid the Python package-vs-same-named-module ambiguity that would shadow the existing cyclone.api module. Verifies: 41 targeted tests (test_acks, test_health, test_api_gets) pass, full pytest is 8 failed / 735 passed / 16 skipped — identical to clean main baseline. Live curl against the running server: GET /api/health -> 200, GET /api/acks -> 200, GET /api/ta1-acks -> 200. See /tmp/refactor-cyclone.md for the full plan. --- backend/src/cyclone/api.py | 143 ++------------------ backend/src/cyclone/api_routers/__init__.py | 1 + backend/src/cyclone/api_routers/acks.py | 104 ++++++++++++++ backend/src/cyclone/api_routers/health.py | 17 +++ backend/src/cyclone/api_routers/ta1_acks.py | 76 +++++++++++ 5 files changed, 209 insertions(+), 132 deletions(-) create mode 100644 backend/src/cyclone/api_routers/__init__.py create mode 100644 backend/src/cyclone/api_routers/acks.py create mode 100644 backend/src/cyclone/api_routers/health.py create mode 100644 backend/src/cyclone/api_routers/ta1_acks.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index e028381..a602897 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -136,6 +136,17 @@ app.add_middleware( allow_headers=["*"], ) +# Resource-group routers. Each module owns its own APIRouter and is +# registered below. New resources go in `cyclone.api_routers.` +# and are wired in here. (Kept as a top-level package rather than nested +# under `cyclone.api` so the existing ``cyclone.api`` module path keeps +# working — Python prefers packages over same-named modules.) +from cyclone.api_routers import acks, health, ta1_acks # noqa: E402 + +app.include_router(health.router) +app.include_router(acks.router) +app.include_router(ta1_acks.router) + def _resolve_payer(name: str) -> PayerConfig: if name not in PAYER_FACTORIES: @@ -161,11 +172,6 @@ def _resolve_payer_835(name: str) -> PayerConfig835: return PAYER_FACTORIES_835[name]() -@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, @@ -659,53 +665,6 @@ async def parse_ta1_endpoint( }) -@app.get("/api/ta1-acks") -def list_ta1_acks_endpoint( - limit: int = Query(100, ge=1, le=1000), -) -> Any: - """Return the list of persisted TA1 ACKs, newest first. - - Mirrors :func:`list_acks_endpoint` — fetches all rows then slices in - Python so the ``total`` field reflects the full row count regardless - of the ``limit`` cap. - """ - rows = store.list_ta1_acks() - items = [_ta1_to_ui(r) for r in rows[:limit]] - return { - "total": len(rows), - "items": items, - } - - -@app.get("/api/ta1-acks/{ack_id}") -def get_ta1_ack_endpoint(ack_id: int) -> dict: - """Return one persisted TA1 ACK row with its parsed detail.""" - row = store.get_ta1_ack(ack_id) - if row is None: - raise HTTPException(status_code=404, detail=f"TA1 ACK {ack_id} not found") - body = _ta1_to_ui(row) - body["raw_ta1_text"] = _serialize_ta1_from_row(row) - body["raw_json"] = row.raw_json - return body - - -def _ta1_to_ui(row: db.Ta1Ack) -> dict: - """Render a Ta1Ack row for the UI (list endpoint shape).""" - return { - "id": row.id, - "control_number": row.control_number, - "ack_code": row.ack_code, - "note_code": row.note_code, - "interchange_date": row.interchange_date.isoformat() - if row.interchange_date else None, - "interchange_time": row.interchange_time, - "sender_id": row.sender_id, - "receiver_id": row.receiver_id, - "source_batch_id": row.source_batch_id, - "parsed_at": row.parsed_at.isoformat() if row.parsed_at else None, - } - - # --------------------------------------------------------------------------- # # 277CA (Claim Acknowledgment) — SP10 # --------------------------------------------------------------------------- # @@ -1943,86 +1902,6 @@ async def activity_stream( # --------------------------------------------------------------------------- # -def _ack_to_ui(row) -> dict: - """Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``. - - Field names match the rest of the Cyclone API (snake_case). The - frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack`` - interface in ``src/types/index.ts``. - """ - return { - "id": row.id, - "source_batch_id": row.source_batch_id, - "accepted_count": row.accepted_count, - "rejected_count": row.rejected_count, - "received_count": row.received_count, - "ack_code": row.ack_code, - "parsed_at": ( - row.parsed_at.isoformat().replace("+00:00", "Z") - if row.parsed_at is not None - else "" - ), - } - - -@app.get("/api/acks") -def list_acks_endpoint( - request: Request, - limit: int = Query(100, ge=1, le=1000), -) -> Any: - """Return the list of persisted 999 ACKs, newest first.""" - rows = store.list_acks() - items = [_ack_to_ui(r) for r in rows[:limit]] - total = len(rows) - returned = len(items) - has_more = total > returned - if _wants_ndjson(request): - return StreamingResponse( - _ndjson_stream_list(items, total, returned, has_more), - media_type="application/x-ndjson", - ) - return { - "items": items, - "total": total, - "returned": returned, - "has_more": has_more, - } - - -@app.get("/api/acks/{ack_id}") -def get_ack_endpoint(ack_id: int) -> dict: - """Return one persisted ACK row with its parsed detail. - - Path param is ``ack_id`` (not ``id``) to avoid shadowing FastAPI's - internal ``id`` name and to keep OpenAPI docs self-describing. - Returns 404 when the ACK is missing — never 500. - """ - from cyclone import db as _db - row = store.get_ack(ack_id) - if row is None: - raise HTTPException( - status_code=404, - detail={"error": "Not found", "detail": f"Ack {ack_id} not found"}, - ) - body = _ack_to_ui(row) - body["raw_json"] = row.raw_json - # Regenerate the X12 text from raw_json so the operator can download - # the actual 999 file. (SP3 P3 follow-up: list endpoint doesn't carry - # the regenerated text to keep payloads small; detail does.) - if row.raw_json: - try: - from cyclone.parsers.models_999 import ParseResult999 - regenerated = ParseResult999.model_validate(row.raw_json) - icn = regenerated.envelope.control_number or "000000001" - body["raw_999_text"] = serialize_999(regenerated, interchange_control_number=icn) - except Exception as exc: # noqa: BLE001 — never 500 on a regen failure - log.warning("Could not regenerate 999 for ack %s: %s", ack_id, exc) - body["raw_999_text"] = None - else: - body["raw_999_text"] = None - return body - - # --------------------------------------------------------------------------- # # 270 / 271 eligibility (SP3 P4 T23–T24) — API-only, no DB persistence # --------------------------------------------------------------------------- # diff --git a/backend/src/cyclone/api_routers/__init__.py b/backend/src/cyclone/api_routers/__init__.py new file mode 100644 index 0000000..2c89bd5 --- /dev/null +++ b/backend/src/cyclone/api_routers/__init__.py @@ -0,0 +1 @@ +"""Resource-group routers. Imported and registered by ``cyclone.api``.""" \ No newline at end of file diff --git a/backend/src/cyclone/api_routers/acks.py b/backend/src/cyclone/api_routers/acks.py new file mode 100644 index 0000000..b2c4bf2 --- /dev/null +++ b/backend/src/cyclone/api_routers/acks.py @@ -0,0 +1,104 @@ +"""``/api/acks`` — list & detail endpoints for the 999 ACK inbox. + +These are the persisted acknowledgment rows produced by +``POST /api/parse-999``. The frontend ``useAcks`` hook re-shapes the +list payload to its ``Ack`` interface in ``src/types/index.ts``. + +The detail endpoint returns the full ``raw_json`` payload plus the +regenerated ``raw_999_text`` so the UI can show "view source" without a +second round-trip. +""" +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import StreamingResponse + +from cyclone.api_helpers import ndjson_stream_list, wants_ndjson +from cyclone.store import store + +router = APIRouter() + +log = logging.getLogger(__name__) + + +def _ack_to_ui(row) -> dict: + """Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``. + + Field names match the rest of the Cyclone API (snake_case). The + frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack`` + interface in ``src/types/index.ts``. + """ + return { + "id": row.id, + "source_batch_id": row.source_batch_id, + "accepted_count": row.accepted_count, + "rejected_count": row.rejected_count, + "received_count": row.received_count, + "ack_code": row.ack_code, + "parsed_at": ( + row.parsed_at.isoformat().replace("+00:00", "Z") + if row.parsed_at is not None + else "" + ), + } + + +@router.get("/api/acks") +def list_acks_endpoint( + request: Request, + limit: int = Query(100, ge=1, le=1000), +) -> Any: + """Return the list of persisted 999 ACKs, newest first.""" + rows = store.list_acks() + items = [_ack_to_ui(r) for r in rows[:limit]] + total = len(rows) + returned = len(items) + has_more = total > returned + if wants_ndjson(request): + return StreamingResponse( + ndjson_stream_list(items, total, returned, has_more), + media_type="application/x-ndjson", + ) + return { + "items": items, + "total": total, + "returned": returned, + "has_more": has_more, + } + + +@router.get("/api/acks/{ack_id}") +def get_ack_endpoint(ack_id: int) -> dict: + """Return one persisted ACK row with its parsed detail. + + Path param is ``ack_id`` (not ``id``) to avoid shadowing FastAPI's + internal ``id`` name and to keep OpenAPI docs self-describing. + Returns 404 when the ACK is missing — never 500. + """ + row = store.get_ack(ack_id) + if row is None: + raise HTTPException( + status_code=404, + detail={"error": "Not found", "detail": f"Ack {ack_id} not found"}, + ) + body = _ack_to_ui(row) + body["raw_json"] = row.raw_json + # Regenerate the X12 text from raw_json so the operator can download + # the actual 999 file. (SP3 P3 follow-up: list endpoint doesn't carry + # the regenerated text to keep payloads small; detail does.) + if row.raw_json: + try: + from cyclone.parsers.models_999 import ParseResult999 + from cyclone.parsers.serialize_999 import serialize_999 + regenerated = ParseResult999.model_validate(row.raw_json) + icn = regenerated.envelope.control_number or "000000001" + body["raw_999_text"] = serialize_999(regenerated, interchange_control_number=icn) + except Exception as exc: # noqa: BLE001 — never 500 on a regen failure + log.warning("Could not regenerate 999 for ack %s: %s", ack_id, exc) + body["raw_999_text"] = None + else: + body["raw_999_text"] = None + return body \ No newline at end of file diff --git a/backend/src/cyclone/api_routers/health.py b/backend/src/cyclone/api_routers/health.py new file mode 100644 index 0000000..8062c44 --- /dev/null +++ b/backend/src/cyclone/api_routers/health.py @@ -0,0 +1,17 @@ +"""``GET /api/health`` — liveness probe. + +Returns the package version so an operator can confirm which build is +serving requests without poking the filesystem. +""" +from __future__ import annotations + +from fastapi import APIRouter + +from cyclone import __version__ + +router = APIRouter() + + +@router.get("/api/health") +def health() -> dict[str, str]: + return {"status": "ok", "version": __version__} \ No newline at end of file diff --git a/backend/src/cyclone/api_routers/ta1_acks.py b/backend/src/cyclone/api_routers/ta1_acks.py new file mode 100644 index 0000000..e1d4e23 --- /dev/null +++ b/backend/src/cyclone/api_routers/ta1_acks.py @@ -0,0 +1,76 @@ +"""``/api/ta1-acks`` — list & detail endpoints for persisted TA1 envelopes. + +TA1 is the interchange-control ACK (ISA/IEA acknowledgement). It's a +single segment, no functional group, no transaction set. Cyclone +persists the parsed fields plus a synthetic ``source_batch_id`` so the +row can sit alongside the 999 / 277CA ack rows without special-casing. + +The detail endpoint also reconstructs the TA1 segment string +(``TA1*...~``) so the operator can copy it into a downstream tool. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException, Query + +from cyclone import db +from cyclone.store import store + +router = APIRouter() + + +def _ta1_to_ui(row: db.Ta1Ack) -> dict: + """Render a Ta1Ack row for the UI (list endpoint shape).""" + return { + "id": row.id, + "control_number": row.control_number, + "ack_code": row.ack_code, + "note_code": row.note_code, + "interchange_date": row.interchange_date.isoformat() + if row.interchange_date else None, + "interchange_time": row.interchange_time, + "sender_id": row.sender_id, + "receiver_id": row.receiver_id, + "source_batch_id": row.source_batch_id, + "parsed_at": row.parsed_at.isoformat() if row.parsed_at else None, + } + + +def _serialize_ta1_from_row(row: db.Ta1Ack) -> str: + """Reconstruct a TA1 segment from the persisted flat row (for the detail endpoint).""" + date_s = row.interchange_date.strftime("%y%m%d") if row.interchange_date else "" + return ( + f"TA1*{row.control_number}*{date_s}*{row.interchange_time or ''}*" + f"{row.ack_code}*{row.note_code or ''}~" + ) + + +@router.get("/api/ta1-acks") +def list_ta1_acks_endpoint( + limit: int = Query(100, ge=1, le=1000), +) -> Any: + """Return the list of persisted TA1 ACKs, newest first. + + Mirrors :func:`cyclone.api_routers.acks.list_acks_endpoint` — fetches all + rows then slices in Python so the ``total`` field reflects the full row + count regardless of the ``limit`` cap. + """ + rows = store.list_ta1_acks() + items = [_ta1_to_ui(r) for r in rows[:limit]] + return { + "total": len(rows), + "items": items, + } + + +@router.get("/api/ta1-acks/{ack_id}") +def get_ta1_ack_endpoint(ack_id: int) -> dict: + """Return one persisted TA1 ACK row with its parsed detail.""" + row = store.get_ta1_ack(ack_id) + if row is None: + raise HTTPException(status_code=404, detail=f"TA1 ACK {ack_id} not found") + body = _ta1_to_ui(row) + body["raw_ta1_text"] = _serialize_ta1_from_row(row) + body["raw_json"] = row.raw_json + return body \ No newline at end of file