[AZ-486] F7 endpoint builders + STC-ARCH-02 (cycle 1 close)

Single source of truth for every /api/<service>/... URL the UI talks to:
src/api/endpoints.ts (25 typed builders) re-exported via the F4 barrel.
Migrates 13 production callsites in admin / annotations / flights /
settings / dataset / auth / api-client / FlightContext / DetectionClasses
to endpoints.* . Adds the STC-ARCH-02 static gate (--mode=api-literals
in scripts/check-arch-imports.mjs, wired into scripts/run-tests.sh)
that fails any new hardcoded /api/<service>/ literal in src/ outside
endpoints.ts and *.test.tsx? files.

Tests: +36 contract assertions in src/api/endpoints.test.ts (every
builder, character-identical), +6 STC-ARCH-02 architecture cases in
tests/architecture_imports.test.ts (single / double / template literal
fail paths, *.test.* exemption, line-comment skip, migrated codebase
pass). Fast profile 167 -> 209 PASS / 13 SKIP / 0 FAIL, +42 new,
0 regressions. Static profile 31 / 31 PASS.

Closes architecture baseline finding F7. Cycle 1 of Phase B closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 23:03:45 +03:00
parent 23746ec61d
commit 8a461a2051
23 changed files with 777 additions and 127 deletions
+78
View File
@@ -0,0 +1,78 @@
// AZ-486 / F7 — Single source of truth for every `/api/<service>/<path>` URL
// the UI talks to today. Closes architecture baseline finding F7.
//
// Every UI callsite of `api.*`, `createSSE`, and raw image/video `src` URLs
// pointing at an API resource MUST go through one of these builders. The
// STC-ARCH-02 static gate (scripts/check-arch-imports.mjs `--mode=api-literals`,
// wired into scripts/run-tests.sh) enforces it.
//
// **Wire-contract invariant**: the strings produced here are character-identical
// to the literals that lived in the source before this refactor and that MSW
// handlers + e2e stubs intercept. Any change to a builder's output is a wire-
// contract change and MUST be coordinated with the backend + the MSW handler
// surface + e2e stubs in the same commit. The accompanying test file
// (`endpoints.test.ts`) pins every URL string and is the contract documentation.
//
// **Why function form (not constants)**: per user direction at task-creation
// time; allows parameter interpolation without callsite re-introducing template
// literals and keeps tree-shaking per-builder under Vite's production rollup.
export const endpoints = {
admin: {
authRefresh: () => '/api/admin/auth/refresh',
authLogin: () => '/api/admin/auth/login',
authLogout: () => '/api/admin/auth/logout',
users: () => '/api/admin/users',
user: (id: string) => `/api/admin/users/${id}`,
classes: () => '/api/admin/classes',
// DetectionClass.id is `number` in the type system; widened to accept
// string for forward-compat if the backend switches the column to UUID.
class: (id: string | number) => `/api/admin/classes/${id}`,
},
annotations: {
classes: () => '/api/annotations/classes',
settingsUser: () => '/api/annotations/settings/user',
settingsSystem: () => '/api/annotations/settings/system',
settingsDirectories: () => '/api/annotations/settings/directories',
annotations: () => '/api/annotations/annotations',
// page-size is currently always 1000 at every callsite; expose it as an
// optional param so future tuning is a single-file change.
annotationsByMedia: (mediaId: string, pageSize: number = 1000) =>
`/api/annotations/annotations?mediaId=${mediaId}&pageSize=${pageSize}`,
annotationImage: (annotationId: string) =>
`/api/annotations/annotations/${annotationId}/image`,
annotationThumbnail: (annotationId: string) =>
`/api/annotations/annotations/${annotationId}/thumbnail`,
annotationEvents: () => '/api/annotations/annotations/events',
// Callers pre-build a URLSearchParams.toString() and pass it through; the
// builder owns the path + `?` only so the wire-contract stays identical.
media: (queryString: string) => `/api/annotations/media?${queryString}`,
mediaFile: (mediaId: string) => `/api/annotations/media/${mediaId}/file`,
mediaItem: (mediaId: string) => `/api/annotations/media/${mediaId}`,
mediaBatch: () => '/api/annotations/media/batch',
dataset: (queryString: string) => `/api/annotations/dataset?${queryString}`,
datasetItem: (annotationId: string) =>
`/api/annotations/dataset/${annotationId}`,
datasetBulkStatus: () => '/api/annotations/dataset/bulk-status',
datasetClassDistribution: () =>
'/api/annotations/dataset/class-distribution',
},
flights: {
// GET (with `?pageSize=...`) lists flights; POST (no query) creates one.
// The query string is owned by the caller (URLSearchParams.toString()) so
// the wire-contract stays identical to today.
collection: (queryString?: string) =>
queryString ? `/api/flights?${queryString}` : '/api/flights',
aircrafts: () => '/api/flights/aircrafts',
aircraft: (id: string) => `/api/flights/aircrafts/${id}`,
flight: (id: string) => `/api/flights/${id}`,
flightWaypoints: (id: string) => `/api/flights/${id}/waypoints`,
flightWaypoint: (flightId: string, waypointId: string) =>
`/api/flights/${flightId}/waypoints/${waypointId}`,
flightLiveGps: (id: string) => `/api/flights/${id}/live-gps`,
},
detect: {
// Trigger detection for a media item. Single-segment service path.
media: (mediaId: string) => `/api/detect/${mediaId}`,
},
} as const