refactor(api): split health + acks + ta1_acks routes into FastAPI APIRouters
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.
This commit is contained in:
+11
-132
@@ -136,6 +136,17 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Resource-group routers. Each module owns its own APIRouter and is
|
||||||
|
# registered below. New resources go in `cyclone.api_routers.<name>`
|
||||||
|
# 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:
|
def _resolve_payer(name: str) -> PayerConfig:
|
||||||
if name not in PAYER_FACTORIES:
|
if name not in PAYER_FACTORIES:
|
||||||
@@ -161,11 +172,6 @@ def _resolve_payer_835(name: str) -> PayerConfig835:
|
|||||||
return PAYER_FACTORIES_835[name]()
|
return PAYER_FACTORIES_835[name]()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
|
||||||
def health() -> dict[str, str]:
|
|
||||||
return {"status": "ok", "version": __version__}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/parse-837")
|
@app.post("/api/parse-837")
|
||||||
async def parse_837(
|
async def parse_837(
|
||||||
request: Request,
|
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
|
# 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
|
# 270 / 271 eligibility (SP3 P4 T23–T24) — API-only, no DB persistence
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Resource-group routers. Imported and registered by ``cyclone.api``."""
|
||||||
@@ -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
|
||||||
@@ -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__}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user