mirror of
https://github.com/azaion/ui.git
synced 2026-06-23 14:41:11 +00:00
[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:
+3
-1
@@ -1,3 +1,5 @@
|
||||
import { endpoints } from './endpoints'
|
||||
|
||||
let accessToken: string | null = null
|
||||
|
||||
/**
|
||||
@@ -85,7 +87,7 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(getApiBase() + '/api/admin/auth/refresh', { method: 'POST', credentials: 'include' })
|
||||
const res = await fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' })
|
||||
if (!res.ok) return false
|
||||
const data = await res.json()
|
||||
setToken(data.token)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { endpoints } from './endpoints'
|
||||
import { endpoints as endpointsViaBarrel } from '../api'
|
||||
|
||||
// AZ-486 / F7 — this test file IS the wire-contract for the UI ↔ nginx layer
|
||||
// (per `module-layout.md`'s "code-derived documentation" pattern referenced in
|
||||
// the task spec). Every builder is asserted to produce the URL literal that
|
||||
// existed in source before the refactor and that MSW handlers + e2e stubs
|
||||
// intercept today. A change to any assertion below is a wire-contract change
|
||||
// and MUST be coordinated with backend + MSW + e2e stubs in the same commit.
|
||||
|
||||
describe('AZ-486 endpoints — wire-contract URLs', () => {
|
||||
describe('AC-1: admin', () => {
|
||||
it('admin.authRefresh', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authRefresh()).toBe('/api/admin/auth/refresh')
|
||||
})
|
||||
|
||||
it('admin.authLogin', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authLogin()).toBe('/api/admin/auth/login')
|
||||
})
|
||||
|
||||
it('admin.authLogout', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authLogout()).toBe('/api/admin/auth/logout')
|
||||
})
|
||||
|
||||
it('admin.users', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.users()).toBe('/api/admin/users')
|
||||
})
|
||||
|
||||
it('admin.user(id) interpolates the id', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.user('abc')).toBe('/api/admin/users/abc')
|
||||
})
|
||||
|
||||
it('admin.classes', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.classes()).toBe('/api/admin/classes')
|
||||
})
|
||||
|
||||
it('admin.class(id) interpolates the id (string)', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.class('cls-7')).toBe('/api/admin/classes/cls-7')
|
||||
})
|
||||
|
||||
it('admin.class(id) interpolates the id (number — DetectionClass.id today)', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.class(42)).toBe('/api/admin/classes/42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: annotations', () => {
|
||||
it('annotations.classes', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.classes()).toBe('/api/annotations/classes')
|
||||
})
|
||||
|
||||
it('annotations.settingsUser', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsUser()).toBe(
|
||||
'/api/annotations/settings/user',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.settingsSystem', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsSystem()).toBe(
|
||||
'/api/annotations/settings/system',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.settingsDirectories', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsDirectories()).toBe(
|
||||
'/api/annotations/settings/directories',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotations', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotations()).toBe(
|
||||
'/api/annotations/annotations',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationsByMedia(mediaId) defaults pageSize=1000', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationsByMedia('m-1')).toBe(
|
||||
'/api/annotations/annotations?mediaId=m-1&pageSize=1000',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationsByMedia(mediaId, pageSize) overrides pageSize', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationsByMedia('m-1', 50)).toBe(
|
||||
'/api/annotations/annotations?mediaId=m-1&pageSize=50',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationImage(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationImage('ann-7')).toBe(
|
||||
'/api/annotations/annotations/ann-7/image',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationThumbnail(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationThumbnail('ann-7')).toBe(
|
||||
'/api/annotations/annotations/ann-7/thumbnail',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationEvents', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationEvents()).toBe(
|
||||
'/api/annotations/annotations/events',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.media(queryString)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.media('page=1&pageSize=50')).toBe(
|
||||
'/api/annotations/media?page=1&pageSize=50',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaFile(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaFile('m-1')).toBe(
|
||||
'/api/annotations/media/m-1/file',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaItem(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaItem('m-1')).toBe(
|
||||
'/api/annotations/media/m-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaBatch', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaBatch()).toBe(
|
||||
'/api/annotations/media/batch',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.dataset(queryString)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.dataset('status=PENDING')).toBe(
|
||||
'/api/annotations/dataset?status=PENDING',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetItem(annotationId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetItem('ann-7')).toBe(
|
||||
'/api/annotations/dataset/ann-7',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetBulkStatus', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetBulkStatus()).toBe(
|
||||
'/api/annotations/dataset/bulk-status',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetClassDistribution', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetClassDistribution()).toBe(
|
||||
'/api/annotations/dataset/class-distribution',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: flights', () => {
|
||||
it('flights.collection() without query', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.collection()).toBe('/api/flights')
|
||||
})
|
||||
|
||||
it('flights.collection(queryString) appends ?queryString', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.collection('pageSize=1000')).toBe(
|
||||
'/api/flights?pageSize=1000',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.aircrafts', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.aircrafts()).toBe('/api/flights/aircrafts')
|
||||
})
|
||||
|
||||
it('flights.aircraft(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.aircraft('ac-1')).toBe(
|
||||
'/api/flights/aircrafts/ac-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flight(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flight('f-1')).toBe('/api/flights/f-1')
|
||||
})
|
||||
|
||||
it('flights.flightWaypoints(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightWaypoints('f-1')).toBe(
|
||||
'/api/flights/f-1/waypoints',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flightWaypoint(flightId, waypointId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightWaypoint('f-1', 'wp-2')).toBe(
|
||||
'/api/flights/f-1/waypoints/wp-2',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flightLiveGps(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightLiveGps('f-1')).toBe(
|
||||
'/api/flights/f-1/live-gps',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: detect', () => {
|
||||
it('detect.media(mediaId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.detect.media('m-1')).toBe('/api/detect/m-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-6: barrel re-export', () => {
|
||||
it('endpoints is the same object when imported from src/api (the F4 barrel)', () => {
|
||||
// Assert
|
||||
expect(endpointsViaBarrel).toBe(endpoints)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -1,2 +1,3 @@
|
||||
export { api, setToken, getToken, getApiBase, setNavigateToLogin } from './client'
|
||||
export { createSSE } from './sse'
|
||||
export { endpoints } from './endpoints'
|
||||
|
||||
Reference in New Issue
Block a user