conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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' });