Files
ThothII/frontend/src/api/client.ts
T

258 lines
12 KiB
TypeScript

import { backendBaseUrl as BASE, joinBackendPath } from "./runtime-config";
import {
clearAuthStateIfCurrent,
getAuthGeneration,
getAuthState,
} from "../auth/authState";
const MAX_ERROR_BODY_BYTES = 8 * 1024;
const safeErrorCodes = new Set([
"auth_forbidden", "auth_invalid_credentials", "auth_not_authorized", "auth_unavailable",
"auth_not_implemented", "authentication_required", "invalid_credentials", "login_rate_limited",
"csrf_failed", "csrf_invalid", "dwh_unreachable", "model_unavailable",
"workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale",
"git_unavailable", "git_auth_failed", "git_non_fast_forward", "connector_unavailable",
"semantic_index_incompatible", "pi_management_forbidden", "pi_management_unavailable",
"pi_management_invalid_config", "pi_management_write_failed",
"catalog_unavailable", "database_conflict", "database_invalid", "database_not_found",
"database_stale", "database_operation_failed", "database_operation_in_progress",
"catalog_target_not_found", "description_consolidation_invalid", "description_consolidation_failed",
"description_generation_request_invalid", "description_generation_run_active",
"description_generation_failed", "description_generation_run_not_found",
"description_generation_no_eligible_targets",
"description_generation_target_ids_duplicate", "metadata_generation_model_unavailable",
"catalog_column_not_found", "catalog_table_not_found",
"workspace_configuration_unavailable",
"sensitive_data_suggestion_request_invalid",
"sensitive_data_suggestion_target_ids_duplicate",
"sensitive_data_suggestion_no_columns",
"sensitive_data_suggestion_payload_too_large",
"sensitive_data_suggestion_invalid_response",
"sensitive_data_suggestion_provider_unavailable",
"sensitive_data_suggestion_failed",
"schema_sync_conflict", "schema_introspection_failed", "schema_request_invalid",
"schema_operation_failed", "sync_run_not_found", "table_stale", "column_stale",
]);
type SafeErrorPayload = {
code: string;
};
const localCodeMessages: Record<string, string> = {
auth_forbidden: "Access is not permitted.",
auth_invalid_credentials: "Invalid username or password.",
auth_not_authorized: "Access is not permitted.",
auth_unavailable: "Authentication is temporarily unavailable. Try again.",
auth_not_implemented: "This sign-in method is not available.",
authentication_required: "Please sign in to continue.",
invalid_credentials: "Invalid username or password.",
login_rate_limited: "Too many sign-in attempts. Try again later.",
csrf_failed: "The security check failed. Please retry.",
csrf_invalid: "The security check failed. Please retry.",
dwh_unreachable: "The database is unreachable. Please retry.",
model_unavailable: "The model provider is unavailable. Please retry.",
workspace_invalid: "The workspace configuration is invalid.",
binding_missing: "The workspace is missing a required binding.",
workspace_not_activatable: "The workspace cannot be activated.",
workspace_stale: "The workspace has changed. Refresh and try again.",
git_unavailable: "The workspace repository is unavailable.",
git_auth_failed: "The workspace repository could not be authenticated.",
git_non_fast_forward: "The workspace repository has moved. Refresh and try again.",
connector_unavailable: "A workspace connector is unavailable.",
semantic_index_incompatible: "The workspace semantic index is incompatible.",
pi_management_forbidden: "Pi management is not permitted",
pi_management_unavailable: "Pi management is unavailable.",
pi_management_invalid_config: "The Pi configuration is invalid.",
pi_management_write_failed: "The Pi configuration could not be saved.",
catalog_unavailable: "The database catalog is unavailable.",
database_conflict: "This workspace already has a database configuration.",
database_invalid: "The database configuration is invalid.",
database_not_found: "The database configuration was not found.",
database_stale: "The database configuration changed. Reload and try again.",
database_operation_failed: "The database operation failed.",
database_operation_in_progress: "A database operation is already in progress.",
catalog_target_not_found: "One or more selected catalog targets were not found.",
description_consolidation_invalid: "The description consolidation request is invalid.",
description_consolidation_failed: "Description consolidation failed.",
description_generation_request_invalid: "The description generation request is invalid.",
description_generation_run_active: "A description generation run is already active.",
description_generation_failed: "Description generation failed.",
description_generation_run_not_found: "The description generation run was not found.",
description_generation_no_eligible_targets: "No eligible catalog tables or columns need description generation.",
description_generation_target_ids_duplicate: "Description generation target IDs must be unique.",
metadata_generation_model_unavailable: "The selected description model is unavailable.",
catalog_column_not_found: "The selected catalog column was not found.",
catalog_table_not_found: "One or more selected catalog tables were not found.",
workspace_configuration_unavailable: "The database workspace configuration is unavailable.",
sensitive_data_suggestion_request_invalid: "Select a database, one or more tables, or one or more columns before requesting sensitive-field suggestions.",
sensitive_data_suggestion_target_ids_duplicate: "Each selected table or column can be included only once.",
sensitive_data_suggestion_no_columns: "The selected scope contains no catalog columns to classify.",
sensitive_data_suggestion_payload_too_large: "The selected structural metadata cannot be divided into safe model requests.",
sensitive_data_suggestion_invalid_response: "The model returned an incomplete or invalid classification. No suggestions were applied.",
sensitive_data_suggestion_provider_unavailable: "The selected model could not complete the request. No suggestions were applied.",
sensitive_data_suggestion_failed: "Sensitive-field suggestions failed before review. No changes were applied.",
schema_sync_conflict: "A schema synchronization is already active or no longer current.",
schema_introspection_failed: "The database schema could not be read safely.",
schema_request_invalid: "The schema request is invalid.",
schema_operation_failed: "The schema operation failed.",
sync_run_not_found: "The synchronization run was not found.",
table_stale: "Table metadata changed. Reload and try again.",
column_stale: "Column metadata changed. Reload and try again.",
};
const localStatusMessages: Record<number, string> = {
401: "Please sign in to continue.",
403: "Access is not permitted.",
404: "The requested resource was not found.",
409: "The request conflicts with current workspace state.",
429: "Too many requests. Try again later.",
500: "Request failed. Please try again.",
502: "The service is unavailable. Please retry.",
503: "The service is temporarily unavailable. Please retry.",
};
const GENERIC_ERROR_MESSAGE = "Request failed. Please try again.";
function localErrorMessage(status: number, code?: string): string {
return (code && localCodeMessages[code]) || localStatusMessages[status] || GENERIC_ERROR_MESSAGE;
}
/**
* Error thrown for non-2xx responses. The message contains only status and a
* whitelisted error code; `.payload` contains a bounded, sanitized JSON shape.
*/
export class ApiError extends Error {
constructor(
readonly status: number,
readonly bodyText: string,
readonly payload: SafeErrorPayload | undefined,
) {
super(localErrorMessage(status, payload?.code));
this.name = "ApiError";
}
get code(): string | undefined {
return this.payload?.code;
}
}
export function apiErrorMessage(error: unknown): string {
return error instanceof ApiError ? error.message : GENERIC_ERROR_MESSAGE;
}
function parseSafeErrorPayload(text: string, truncated: boolean): SafeErrorPayload | undefined {
if (truncated || text.length === 0) return undefined;
let parsed: unknown;
try { parsed = JSON.parse(text); } catch { return undefined; }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
const source = parsed as Record<string, unknown>;
if (typeof source.code !== "string" || !safeErrorCodes.has(source.code)) return undefined;
return { code: source.code };
}
async function readBoundedText(response: Response): Promise<{ text: string; truncated: boolean }> {
const reader = response.body?.getReader();
if (!reader) return { text: "", truncated: false };
const decoder = new TextDecoder();
let text = "";
let bytes = 0;
let truncated = false;
try {
while (true) {
const next = await reader.read();
if (next.done) break;
const remaining = MAX_ERROR_BODY_BYTES - bytes;
if (remaining <= 0) {
truncated = true;
await reader.cancel();
break;
}
const chunk = next.value.byteLength > remaining ? next.value.slice(0, remaining) : next.value;
bytes += chunk.byteLength;
text += decoder.decode(chunk, { stream: next.value.byteLength <= remaining });
if (next.value.byteLength > remaining) {
truncated = true;
await reader.cancel();
break;
}
}
} catch (error) {
try { await reader.cancel(); } catch { /* preserve the original read failure */ }
throw error;
} finally {
text += decoder.decode();
try { reader.releaseLock(); } catch { /* a completed browser reader may already be released */ }
}
return { text, truncated };
}
function requestHeaders(init: RequestInit | undefined): Headers {
const headers = new Headers(init?.headers);
// Fetch supplies the multipart boundary for FormData. Declaring JSON here
// would prevent Fastify from parsing an imported workspace bundle.
if (
init?.body != null
&& !(typeof FormData !== "undefined" && init.body instanceof FormData)
&& !headers.has("content-type")
) {
headers.set("content-type", "application/json");
}
return headers;
}
async function request(path: string, init?: RequestInit): Promise<Response> {
const url = joinBackendPath(BASE, path);
assertSameOriginRequestUrl(url);
const dispatchGeneration = getAuthGeneration();
const headers = requestHeaders(init);
const method = (init?.method ?? "GET").toUpperCase();
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
headers.delete("X-ThothII-CSRF");
const csrfToken = getAuthState()?.csrfToken;
if (csrfToken) headers.set("X-ThothII-CSRF", csrfToken);
}
const res = await fetch(url, {
...init,
credentials: "same-origin",
headers,
});
if (res.status === 401) clearAuthStateIfCurrent(dispatchGeneration);
if (!res.ok) {
const { text, truncated } = await readBoundedText(res);
const payload = parseSafeErrorPayload(text, truncated);
throw new ApiError(res.status, "", payload);
}
return res;
}
/** Refuse a credentialed cross-origin base before the browser can send a request. */
export function assertSameOriginRequestUrl(url: string): void {
if (typeof window === "undefined") {
if (/^https?:\/\//i.test(url)) throw new Error("The browser must use the same-origin /api route");
return;
}
const parsed = new URL(url, window.location.origin);
if (parsed.origin !== window.location.origin) {
throw new Error("The browser must use the same-origin /api route");
}
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
// Only declare a JSON content-type when we actually send a body. Body-less
// POSTs (resume, close) would otherwise make Fastify reject the empty body
// with FST_ERR_CTP_EMPTY_JSON_BODY (400).
const res = await request(path, init);
if (res.status === 204) return undefined as T;
// Accepted fire-and-forget endpoints may legitimately return 202 with no
// representation. Keep apiFetch useful for both 202 and 204 contracts.
const body = await res.text();
return body ? (JSON.parse(body) as T) : (undefined as T);
}
/** Download registry bundles without attempting to parse their ZIP body as JSON. */
export async function apiFetchBlob(path: string, init?: RequestInit): Promise<Blob> {
return (await request(path, init)).blob();
}
export { BASE };