Skip to content

feat(api): standardize list responses on a shared {total, items} envelope - #90

Merged
tyler-rich merged 2 commits into
devfrom
claude/list-envelope-standardization-baymks
Jul 25, 2026
Merged

feat(api): standardize list responses on a shared {total, items} envelope#90
tyler-rich merged 2 commits into
devfrom
claude/list-envelope-standardization-baymks

Conversation

@tyler-rich

@tyler-rich tyler-rich commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Closes the half of L13 / APIR-8 that #61 deliberately deferred. That PR was held to the single entriesitems audit rename per maintainer direction; the broader consolidation was tracked in docs/ROADMAP.md § Backend structural cleanup, and this is it.

What changed

Thirteen endpoints that returned bare JSON arrays now return the shared {total, items} envelope — the shape /api/scans/history, /api/scans/{id}/findings, and /api/audit already used:

/api/registries · /api/git-credentials · /api/users · /api/notifications · /api/scan-schedules · /api/api-tokens · /api/backups · /api/filter-presets · /api/docker-environments · /api/trivy/vex-documents · /api/trivy/ignore-rules · /api/auth/sessions · /api/scans/{id}/artifacts

They remain unpaginated, so total always equals items.length today. The point of enveloping them now is that adding pagination later becomes an additive limit/offset parameter rather than a second breaking change.

The split is a rule, not a per-endpoint call. Persisted resource collections — rows that grow with usage, where a count is a meaningful answer — take the envelope. Fixed enumerations and live, non-persisted data stay bare arrays, because total there answers a question nobody asks. Four endpoints are therefore deliberately unchanged:

Endpoint Why it stays bare
GET /api/registries/options id/name value list for a select control
GET /api/git-credentials/options same
GET /api/notifications/events fixed list[str] vocabulary
GET /api/docker-environments/{id}/images live enumeration off a Docker daemon; nothing persisted

The rule and these exceptions are documented in CONTRIBUTING.md § API conventions so a future review reads them as a decision rather than as drift — the same treatment the other deferrals got tracking references for.

Behind shared helpers, per the ROADMAP note. New backend/app/api/pagination.py holds a generic Page model and a full_page(items) constructor; each route declares response_model=Page[ThingOut] and returns full_page([...]) rather than hand-rolling the dict. The three pre-existing envelopes (AuditPageOut, ScanHistoryPage, FindingsPage) are re-based onto it as subclasses, which collapses the duplicated field declarations while keeping their OpenAPI component names unchanged — a bare parameterized Page would have renamed them to Page_AuditEntryOut_ and churned a future generated client for no gain.

GET /api/scans was left frozen and only deprecated. It is the one bare array that is paginated (limit/offset, no total), so the one the envelope would materially fix — but its shape is a documented frozen contract from Phase P4. It now carries deprecated=True plus a description naming both the replacement (GET /api/scans/history) and the reason, so a reader of the generated client sees the why and the where-to-go rather than only a flag. listScans in the TS client carries the matching @deprecated JSDoc. This closes APIR-8 as its own fix direction stated it.

Frontend

The change is absorbed at the client boundary: a new apiList helper in frontend/src/api/client.ts unwraps the envelope, so the thirteen client functions keep returning a promise of a plain array. No page component and no existing test changed — the SPA's page-level tests mock the API-client functions rather than fetch, so all 18 frontend test files passed untouched. FindingsPage/ScanHistoryPage became aliases of a shared generic Page interface.

Contract change

Breaking for external consumers driving these endpoints with an API token: read rows from .items. Recorded in CHANGELOG.md under Unreleased with the full endpoint list and an action-required note. The web UI is unaffected.

Verification

  • Backend: 582 passed; ruff and black --check clean.
  • Frontend: 58 passed across 18 files; ESLint, Prettier, tsc --noEmit, and vite build clean.
  • New backend/tests/test_list_envelope.py parametrizes over every enveloped endpoint (shape, key set, total == len(items)), over the bare-array exceptions (asserting they stay bare), that total tracks rows as a collection grows, and that /api/scans is still a bare array and carries the deprecation marker naming its replacement.
  • ~34 existing assertions across 15 test modules updated mechanically to read the items key.
  • Not verified in this environment: docker compose up / /healthz — no Docker daemon available here. This PR touches no Dockerfile, Compose, or health-endpoint code.

Docs: CONTRIBUTING.md § API conventions (new), CHANGELOG.md, docs/reviews/STATUS.md (L13/APIR-8 moved out of § 2 "Deferred by decision"), docs/ROADMAP.md (list-envelope half struck; the secret-CRUD-router consolidation remains open). See docs/ARCHIVE.md § Deviations for the changes made in this pass.

…lope

Closes the half of L13 / APIR-8 that #61 deliberately deferred (that PR was
held to the single `entries`->`items` audit rename; the broader consolidation
was tracked in docs/ROADMAP.md § Backend structural cleanup).

Thirteen endpoints that returned bare JSON arrays now return {total, items}:
registries, git credentials, users, notification channels, scan schedules,
API tokens, backups, filter presets, Docker environments, Trivy VEX documents,
Trivy ignore rules, auth sessions, and scan artifacts. They stay unpaginated,
so `total` equals `len(items)` today — enveloping them now makes adding
pagination later an additive change rather than a second breaking one.

The split is a rule, not a per-endpoint call: persisted resource collections
take the envelope; fixed enumerations and live non-persisted data stay bare.
Four endpoints are therefore deliberately unchanged (`/registries/options`,
`/git-credentials/options`, `/notifications/events`,
`/docker-environments/{id}/images`) and are documented as such in
CONTRIBUTING.md § API conventions so a later review reads them as a decision.

Implemented behind shared helpers per the ROADMAP note: new
`app/api/pagination.py` holds `Page[ItemT]` and `full_page(items)`, and the
three pre-existing envelopes (AuditPageOut, ScanHistoryPage, FindingsPage) are
re-based onto it as subclasses, which keeps their OpenAPI component names
stable. The frontend absorbs the change at the client boundary via `apiList`,
so client signatures stay `Promise<T[]>` and no page component changed.

`GET /api/scans` keeps its frozen Phase-P4 bare-array contract and is only
marked deprecated, with a description naming the replacement
(`GET /api/scans/history`) and the reason — exactly APIR-8's stated fix
direction.

Breaking for API-token consumers; recorded in CHANGELOG.md with the endpoint
list and an action-required note. See docs/ARCHIVE.md § Deviations for the
changes made in this pass.
…r seam

Two follow-ups on the list-envelope work, no behavior change:

- Expand the `apiList` comment to say explicitly that it discards `total` and
  returns only `items` — that is what keeps the thirteen client functions on
  their existing `Promise<T[]>` signatures — and that the envelope is therefore
  not purely a server-side detail: when real pagination lands, `total` has to be
  threaded back through to any page wanting a "showing N of M" count.

- Add one test unwrapping a verbatim serialized `full_page()` payload. The
  backend proves it sends the envelope and the page-level tests mock the client
  functions, so nothing previously checked that the client's unwrapping matches
  the server's shape; both suites would have stayed green while the app broke at
  runtime.
@tyler-rich
tyler-rich merged commit 0d15e0d into dev Jul 25, 2026
4 checks passed
@tyler-rich
tyler-rich deleted the claude/list-envelope-standardization-baymks branch July 25, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant