CI / changes (push) Successful in 5s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 19s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m6s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m1s
CI / docker-bird (push) Successful in 38s
CI / bird2 (push) Successful in 16s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m1s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 1m32s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been cancelled
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been cancelled
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been cancelled
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been cancelled
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been cancelled
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been cancelled
106 lines
3.2 KiB
TypeScript
106 lines
3.2 KiB
TypeScript
import { browser } from '$app/environment';
|
||
|
||
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;
|
||
}
|