Files
mcp-didi/src/client.ts
T

147 lines
4.3 KiB
TypeScript

/**
* 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<string, QueryValue>;
/** Um erro Pydantic de validação (422), um item de `[{type,loc,msg,...}]`. */
interface PydanticValidationError {
type?: string;
loc?: Array<string | number>;
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<T>(
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
opts?: { query?: Query; body?: unknown },
): Promise<T> {
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<string, unknown>)
? (parsed as Record<string, unknown>).detail
: parsed;
throw new DidiApiError(res.status, detail, path);
}
return parsed as T;
}
get<T>(path: string, query?: Query): Promise<T> {
return this.request<T>("GET", path, { query });
}
post<T>(path: string, body?: unknown, query?: Query): Promise<T> {
return this.request<T>("POST", path, { body, query });
}
put<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("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<T>(path: string, query?: Query): Promise<T> {
return this.request<T>("DELETE", path, { query });
}
}