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
This reverts commit 6c8e7c55ad.
186 lines
5.8 KiB
TypeScript
186 lines
5.8 KiB
TypeScript
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 {};
|
||
}
|