/** * Cliente REST mínimo para a API do Open Notebook self-hosted ("Didi", * didi.descomplicar.pt) — Bearer token, JSON, base path `/api`. * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ const REQUEST_TIMEOUT_MS = 30_000; type QueryValue = string | number | boolean | undefined; type Query = Record; /** Um erro Pydantic de validação (422), um item de `[{type,loc,msg,...}]`. */ interface PydanticValidationError { type?: string; loc?: Array; msg?: string; [key: string]: unknown; } /** * Erro HTTP não-2xx da API do Open Notebook. Preserva o `detail` bruto * (string, ou array de erros Pydantic num 422) para diagnóstico — nunca * o engole, é a única forma de perceber um 422 de schema (ex. enum * `provider` inválido). */ export class DidiApiError extends Error { constructor( public status: number, public detail: unknown, public path: string, ) { super(`Open Notebook API ${status} em ${path}: ${DidiApiError.formatDetail(detail)}`); this.name = "DidiApiError"; } private static formatDetail(detail: unknown): string { if (detail === undefined || detail === null) return "sem detalhe"; if (typeof detail === "string") return detail; if (Array.isArray(detail)) { return detail .map((e: PydanticValidationError) => { if (e && typeof e === "object" && ("loc" in e || "msg" in e)) { const loc = Array.isArray(e.loc) ? e.loc.join(".") : "?"; return `[${loc}] ${e.msg ?? JSON.stringify(e)}`; } return JSON.stringify(e); }) .join("; "); } try { return JSON.stringify(detail); } catch { return String(detail); } } } function buildUrl(baseUrl: string, path: string, query?: Query): string { const url = new URL(`${baseUrl.replace(/\/+$/, "")}/api${path}`); if (query) { for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null) { url.searchParams.set(key, String(value)); } } } return url.toString(); } export class DidiClient { constructor( private readonly baseUrl: string, private readonly token: string, ) {} private async request( method: "GET" | "POST" | "PUT" | "DELETE", path: string, opts?: { query?: Query; body?: unknown }, ): Promise { const url = buildUrl(this.baseUrl, path, opts?.query); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); let res: Response; try { res = await fetch(url, { method, headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json", }, body: opts?.body !== undefined ? JSON.stringify(opts.body) : undefined, signal: controller.signal, }); } catch (err) { if (err instanceof Error && err.name === "AbortError") { throw new DidiApiError(0, `Timeout após ${REQUEST_TIMEOUT_MS / 1000}s`, path); } throw new DidiApiError(0, err instanceof Error ? err.message : String(err), path); } finally { clearTimeout(timer); } const raw = await res.text(); let parsed: unknown = undefined; if (raw.length > 0) { try { parsed = JSON.parse(raw); } catch { parsed = raw; } } if (!res.ok) { const detail = parsed && typeof parsed === "object" && "detail" in (parsed as Record) ? (parsed as Record).detail : parsed; throw new DidiApiError(res.status, detail, path); } return parsed as T; } get(path: string, query?: Query): Promise { return this.request("GET", path, { query }); } post(path: string, body?: unknown, query?: Query): Promise { return this.request("POST", path, { body, query }); } put(path: string, body?: unknown): Promise { return this.request("PUT", path, { body }); } /** * `query` é uma extensão ao contrato mínimo — alguns deletes da API * precisam de query params (`delete_notebook?delete_exclusive_sources=`, * `delete_credential?migrate_to=`). */ delete(path: string, query?: Query): Promise { return this.request("DELETE", path, { query }); } }