api.ts (3424B)
1 // web/src/util/api.ts - the conductor's API, unwrapped 2 3 import { bearer, forget, refresh, signedIn } from './auth'; 4 5 export type Page<T> = { rows: T[]; offset: number; total: number; next?: string }; 6 7 export class ApiError extends Error { 8 name: string; 9 status: number; 10 details?: unknown; 11 12 constructor(status: number, body: { name?: string; message?: string; details?: unknown }) { 13 super(body.message || `request failed: ${status}`); 14 this.name = body.name || 'Error'; 15 this.status = status; 16 this.details = body.details; 17 } 18 } 19 20 type Options = { 21 method?: string; 22 body?: unknown; 23 query?: Record<string, string | number | undefined>; 24 }; 25 26 function urlFor(path: string, query?: Options['query']): string { 27 const url = new URL(`api/v1${path}`, location.origin + location.pathname); 28 for (const [key, value] of Object.entries(query ?? {})) { 29 if (value !== undefined) url.searchParams.set(key, String(value)); 30 } 31 return url.pathname + url.search; 32 } 33 34 async function send(url: string, options: Options, token: string | null): Promise<Response> { 35 const headers: Record<string, string> = {}; 36 if (token) headers.authorization = `Bearer ${token}`; 37 if (options.body !== undefined) headers['content-type'] = 'application/json'; 38 39 return fetch(url, { 40 method: options.method ?? 'GET', 41 headers, 42 body: options.body === undefined ? undefined : JSON.stringify(options.body), 43 }); 44 } 45 46 async function authorized(url: string, options: Options): Promise<Response> { 47 const res = await send(url, options, await bearer()); 48 if (res.status !== 401 || !signedIn()) return res; 49 50 try { 51 await refresh(); 52 } catch { 53 forget(); 54 } 55 return send(url, options, await bearer()); 56 } 57 58 export async function request<T>(path: string, options: Options = {}): Promise<T> { 59 const url = path.startsWith('http') ? path : urlFor(path, options.query); 60 const res = await authorized(url, options); 61 62 if (res.status === 204) return undefined as T; 63 64 let body: { ok?: boolean; data?: T; error?: { name?: string; message?: string; details?: unknown } }; 65 try { 66 body = await res.json(); 67 } catch { 68 throw new ApiError(res.status, { message: `the conductor did not answer in JSON (${res.status})` }); 69 } 70 71 if (!res.ok || body.ok === false) throw new ApiError(res.status, body.error ?? {}); 72 return body.data as T; 73 } 74 75 // The envelope's pagination, flattened onto the rows it describes. 76 export async function page<T>(path: string, options: Options = {}): Promise<Page<T>> { 77 const url = path.startsWith('http') ? path : urlFor(path, options.query); 78 const res = await authorized(url, options); 79 80 const body = await res.json().catch(() => ({})); 81 if (!res.ok || body.ok === false) throw new ApiError(res.status, body.error ?? {}); 82 83 const block = body.pagination ?? { offset: 0, total: (body.data ?? []).length }; 84 return { rows: body.data ?? [], offset: block.offset, total: block.total, next: block.next }; 85 } 86 87 export const get = <T>(path: string, query?: Options['query']) => request<T>(path, { query }); 88 export const post = <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }); 89 export const patch = <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }); 90 export const put = <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body }); 91 export const del = <T>(path: string) => request<T>(path, { method: 'DELETE' });