Files
EvoBGP/web/src/lib/api/client.ts
T
Denozordec 9163aeca1e
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m1s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m2s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / docker-go-prime (push) Has been skipped
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Has been skipped
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been skipped
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been skipped
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been skipped
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been skipped
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been skipped
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been skipped
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been skipped
Revert "feat: add router-lists catalog API client helper"
This reverts commit 6c8e7c55ad.
2026-04-07 23:21:17 +07:00

186 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { browser } from '$app/environment';
import type {
AsEntriesResponse,
CdnSourcesResponse,
DomainEntriesResponse,
IpRangeEntriesResponse,
JobRow,
ModuleType,
RevisionPrefix,
RevisionPrefixesResponse
} from './types.js';
export const TOKEN_STORAGE_KEY = 'evobgp_api_token';
export type Problem = {
type?: string;
title?: string;
status?: number;
detail?: string;
};
function getToken(): string | null {
if (!browser) return null;
return localStorage.getItem(TOKEN_STORAGE_KEY);
}
function mergeHeaders(init?: RequestInit, extraHeaders?: Record<string, string>): 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<Response> {
if (!browser) throw new Error('API is only available in the browser');
return fetch(path, { ...init, headers: mergeHeaders(init) });
}
/** GET / DELETE без тела */
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
const res = await apiFetch(path, init);
return parseResponse<T>(res);
}
/** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */
export async function apiMutate<T = void>(
path: string,
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
body?: unknown,
opts?: { idempotent?: boolean }
): Promise<T> {
const headers: Record<string, string> = {};
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<T>(res);
}
async function parseResponse<T>(res: Response): Promise<T> {
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<JobRow> {
const pollMs = opts?.pollMs ?? 400;
const timeoutMs = opts?.timeoutMs ?? 120000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const j = await apiJSON<JobRow>(`/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<T>(path: string, limit = 500): Promise<T[]> {
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<RevisionPrefix[]> {
const items = await apiPageAll<RevisionPrefix>(`/v1/revisions/${revisionId}/prefixes`);
return items;
}
export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) {
if (moduleType === 'DOMAINS') {
const entries = await apiPageAll<DomainEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/domain-entries`
);
return { domains: entries };
}
if (moduleType === 'AS_PREFIXES') {
const entries = await apiPageAll<AsEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/as-entries`
);
return { asns: entries };
}
if (moduleType === 'CDN_CIDRS') {
const entries = await apiPageAll<CdnSourcesResponse['items'][number]>(
`/v1/modules/${moduleId}/cdn-sources`
);
return { cdnSources: entries };
}
if (moduleType === 'IP_RANGES') {
const entries = await apiPageAll<IpRangeEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/ip-range-entries`
);
return { ipRanges: entries };
}
return {};
}