import type { AsEntriesResponse, CdnSourcesResponse, DomainEntriesResponse, IpRangeEntriesResponse, JobRow, ModuleType, RevisionPrefix, RevisionPrefixesResponse, } from '@/types/api' export const TOKEN_STORAGE_KEY = 'evobgp_api_token' /** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */ export const DEV_API_TOKEN = 'dev' export type Problem = { type?: string title?: string status?: number detail?: string } /** Убирает пробелы и опциональный префикс Bearer (UI часто вставляет «Bearer dev»). */ export function normalizeApiToken(raw: string): string { let t = raw.trim() if (/^bearer\s+/i.test(t)) { t = t.replace(/^bearer\s+/i, '').trim() } return t } function getToken(): string | null { if (typeof window === 'undefined') return null const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY) if (!raw) return null const normalized = normalizeApiToken(raw) return normalized || null } export function setToken(token: string | null): void { if (typeof window === 'undefined') return if (!token) { window.localStorage.removeItem(TOKEN_STORAGE_KEY) return } const normalized = normalizeApiToken(token) if (normalized) window.localStorage.setItem(TOKEN_STORAGE_KEY, normalized) else window.localStorage.removeItem(TOKEN_STORAGE_KEY) } function mergeHeaders(init?: RequestInit, extraHeaders?: Record): Headers { const h = new Headers(init?.headers) if (!h.has('Accept')) h.set('Accept', 'application/json') const t = getToken() if (t && !h.has('Authorization')) h.set('Authorization', `Bearer ${t}`) if (extraHeaders) { for (const [k, v] of Object.entries(extraHeaders)) { if (!h.has(k)) h.set(k, v) } } return h } /** * Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost). * Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback. */ function newIdempotencyKey(): string { const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined if (c?.randomUUID) return c.randomUUID() if (c?.getRandomValues) { const buf = new Uint8Array(16) c.getRandomValues(buf) buf[6] = (buf[6]! & 0x0f) | 0x40 buf[8] = (buf[8]! & 0x3f) | 0x80 const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join('') return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` } return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}` } export class ApiError extends Error { constructor( public readonly status: number, message: string, public readonly problem?: Problem, ) { super(message) } } export async function apiFetch(path: string, init?: RequestInit): Promise { if (typeof window === 'undefined') throw new Error('API is only available in the browser') return fetch(path, { ...init, headers: mergeHeaders(init) }) } /** GET / DELETE без тела */ export async function apiJSON(path: string, init?: RequestInit): Promise { const res = await apiFetch(path, init) return parseResponse(res) } /** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */ export async function apiMutate( path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', body?: unknown, opts?: { idempotent?: boolean }, ): Promise { const headers: Record = {} if (body !== undefined) headers['Content-Type'] = 'application/json' if (opts?.idempotent !== false) { headers['Idempotency-Key'] = newIdempotencyKey() } const res = await fetch(path, { method, headers: mergeHeaders({ headers }, headers), body: body !== undefined ? JSON.stringify(body) : undefined, }) return parseResponse(res) } async function parseResponse(res: Response): Promise { if (res.status === 204 || res.status === 205) return undefined as T const text = await res.text() if (!res.ok) { let problem: Problem | undefined let detail = `HTTP ${res.status}` try { problem = JSON.parse(text) as Problem detail = problem.detail ?? problem.title ?? detail } catch { if (text) detail = text } throw new ApiError(res.status, detail, problem) } if (!text) return undefined as T return JSON.parse(text) as T } const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled']) /** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */ export async function waitForJob( jobId: string, opts?: { pollMs?: number; timeoutMs?: number }, ): Promise { const pollMs = opts?.pollMs ?? 400 const timeoutMs = opts?.timeoutMs ?? 120000 const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { const j = await apiJSON(`/v1/jobs/${jobId}`) if (terminalJobStatuses.has(j.status)) return j await new Promise((r) => setTimeout(r, pollMs)) } throw new Error(`Таймаут ожидания задачи ${jobId}`) } export async function apiPageAll(path: string, limit = 500): Promise { const items: T[] = [] let cursor: string | null = null while (true) { const [basePath, rawQuery = ''] = path.split('?') const query = new URLSearchParams(rawQuery) if (!query.has('limit')) query.set('limit', String(limit)) if (cursor) query.set('cursor', cursor) else query.delete('cursor') const page = await apiJSON<{ items?: T[]; next_cursor?: string | null; has_more?: boolean }>( `${basePath}?${query.toString()}`, ) items.push(...(page.items ?? [])) if (!page.has_more || !page.next_cursor) break cursor = page.next_cursor } return items } export async function fetchRevisionPrefixesAll(revisionId: string): Promise { const items = await apiPageAll(`/v1/revisions/${revisionId}/prefixes`) return items } export async function fetchRevisionPrefixesResponse( revisionId: string, cursor?: string | null, limit = 500, ): Promise { const query = new URLSearchParams({ limit: String(limit) }) if (cursor) query.set('cursor', cursor) return apiJSON(`/v1/revisions/${revisionId}/prefixes?${query.toString()}`) } export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) { if (moduleType === 'DOMAINS') { const entries = await apiPageAll( `/v1/modules/${moduleId}/domain-entries`, ) return { domains: entries } } if (moduleType === 'AS_PREFIXES') { const entries = await apiPageAll( `/v1/modules/${moduleId}/as-entries`, ) return { asns: entries } } if (moduleType === 'CDN_CIDRS') { const entries = await apiPageAll( `/v1/modules/${moduleId}/cdn-sources`, ) return { cdnSources: entries } } if (moduleType === 'IP_RANGES') { const entries = await apiPageAll( `/v1/modules/${moduleId}/ip-range-entries`, ) return { ipRanges: entries } } return {} }