import { getSetCookies } from '@std/http/cookie'; import { generateTotp } from '@stdext/crypto/totp'; export interface API_CLIENT { fetch: (url: string, options?: FETCH_OPTIONS, transform?: (obj: any) => any) => Promise; } export type API_CONFIG = { protocol: string; hostname: string; port: number; prefix: string; }; const DEFAULT_API_CONFIG: API_CONFIG = { protocol: 'http:', hostname: 'localhost', port: 80, prefix: '' }; export interface RETRY_OPTIONS { limit: number; methods: string[]; status_codes: number[]; } export interface FETCH_OPTIONS extends RequestInit { retry?: RETRY_OPTIONS; json?: Record; done?: (response: Response) => void; session?: Record; totp_token?: string; } const DEFAULT_TRANSFORM = (response_json: any) => { return response_json; }; export function api(api_config?: Record): API_CLIENT { const config: API_CONFIG = { ...DEFAULT_API_CONFIG, ...(api_config ?? {}) }; return { fetch: async (url: string, options?: FETCH_OPTIONS, transform?: (obj: any) => any) => { const prefix: string = `${config.protocol}//${config.hostname}:${config.port}${config.prefix}`; const retry: RETRY_OPTIONS = options?.retry ?? { limit: 0, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], status_codes: [500, 502, 503, 504, 521, 522, 524] }; const content_type = options?.json ? 'application/json' : 'text/plain'; const headers = new Headers(options?.headers ?? {}); headers.append('accept', 'application/json'); if (options?.json || options?.body) { headers.append('content-type', content_type); } if (options?.session) { const cookies = getSetCookies(headers); cookies.push({ name: options.totp_token ?? 'totp', value: await generateTotp(options.session.secret), maxAge: 30, expires: Date.now() + 30_000, path: '/' }); for (const cookie of cookies) { headers.append(`x-${cookie.name}`, cookie.value); } headers.append('cookie', cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')); } const request_options: RequestInit = { body: options?.json ? JSON.stringify(options.json, null, 2) : options?.body, method: options?.method ?? 'GET', credentials: options?.credentials ?? 'include', redirect: options?.redirect ?? 'follow', headers }; const response_transform = transform ?? DEFAULT_TRANSFORM; let retries = 0; let delay = 1000; const resolved_url = `${prefix}${url}`; do { const response: Response = await fetch(resolved_url, request_options); if ( retries < retry.limit && retry.status_codes.includes(response.status) && retry.methods.includes(request_options.method ?? 'GET') ) { ++retries; await new Promise((resolve) => setTimeout(resolve, delay)); delay *= 2; continue; } if (response.status > 400) { const error_response = await response.json(); throw new Error('Bad Request', { cause: error_response?.cause ?? JSON.stringify(error_response) }); } const data = await response.json(); const transformed = await response_transform(data); if (options?.done) { options.done(response); } return transformed; } while (retries < retry.limit); } }; }