/** * The typed API client (openapi-fetch over the generated `schema.d.ts`). Same origin as the SPA (`/me`, `/sync`, …). * Every call carries the headers of SPEC §14.3.1: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version`, * `X-Kvitt-App-Version-Name`, `Accept-Language`, and `Authorization: Bearer ` while signed in. A 401 signs the * session out (`unauthorized` or `device_revoked`), which sends the router to /app/login. */ import createClient, { type Client } from 'openapi-fetch'; import { BUILD } from '../app/buildInfo'; import type { Language } from '../i18n/language'; import type { paths } from './schema'; import type { SessionStore } from './session'; import type { ErrorBody, ErrorCode } from './types'; /** The protocol version this client speaks (SPEC §14.4). */ export const PROTOCOL_VERSION = '1'; /** The operations' header parameters are set by the middleware below, so callers never pass them. */ type WithoutHeaderParams = Operation extends { parameters: infer Params } ? Omit & { parameters: Omit & { header?: never } } : Operation; export type ApiPaths = { [Path in keyof paths]: { [Method in keyof paths[Path]]: WithoutHeaderParams }; }; export type Api = Client; export interface ApiOptions { /** The origin the SPA is served from; the API lives at its root (OpenAPI `servers`). */ baseUrl: string; session: SessionStore; language: () => Language; fetch?: (request: Request) => Promise; } /** An API failure: an error body of SPEC §14.3.2, or `network` when the server could not be reached. */ export class ApiError extends Error { constructor( readonly status: number, readonly code: ErrorCode | 'network' | 'unexpected', readonly field: string | null, message: string, readonly retryAfterSeconds: number | null = null, ) { super(message); this.name = 'ApiError'; } } function isErrorBody(value: unknown): value is ErrorBody { return typeof value === 'object' && value !== null && typeof (value as { code?: unknown }).code === 'string'; } async function errorCodeOf(response: Response): Promise { try { const body: unknown = await response.clone().json(); return isErrorBody(body) ? body.code : null; } catch (error) { if (error instanceof SyntaxError || error instanceof TypeError) return null; throw error; } } export function createApi(options: ApiOptions): Api { const client = createClient({ baseUrl: options.baseUrl, fetch: options.fetch }); client.use({ onRequest({ request }) { request.headers.set('X-Kvitt-Protocol', PROTOCOL_VERSION); request.headers.set('X-Kvitt-App-Version', String(BUILD.versionCode)); request.headers.set('X-Kvitt-App-Version-Name', BUILD.versionName); request.headers.set('Accept-Language', options.language()); const session = options.session.get(); if (session !== null) request.headers.set('Authorization', `Bearer ${session.token}`); return request; }, async onResponse({ request, response }) { if (response.status === 401 && request.headers.has('Authorization')) { const code = await errorCodeOf(response); options.session.signOut(code === 'device_revoked' ? 'device_revoked' : 'unauthorized'); } return response; }, }); return client; } interface Outcome { data?: D; error?: unknown; response: Response; } async function settle(pending: Promise>): Promise> { try { return await pending; } catch (cause) { if (cause instanceof DOMException && cause.name === 'AbortError') throw cause; throw new ApiError(0, 'network', null, cause instanceof Error ? cause.message : 'network error'); } } function failure(outcome: Outcome): ApiError { const { response, error } = outcome; const retryAfter = response.headers.get('Retry-After'); const retryAfterSeconds = retryAfter === null ? null : Number(retryAfter); if (isErrorBody(error)) { return new ApiError(response.status, error.code, error.field, error.message, retryAfterSeconds); } return new ApiError(response.status, 'unexpected', null, `HTTP ${response.status}`, retryAfterSeconds); } /** The response body of a successful call; throws ApiError otherwise. */ export async function unwrap(pending: Promise>): Promise { const outcome = await settle(pending); if (!outcome.response.ok) throw failure(outcome); if (outcome.data === undefined) throw new ApiError(outcome.response.status, 'unexpected', null, 'empty body'); return outcome.data; } /** For calls answered with 204 No Content; throws ApiError on failure. */ export async function unwrapEmpty(pending: Promise>): Promise { const outcome = await settle(pending); if (!outcome.response.ok) throw failure(outcome); }