Files
metabase/enterprise/frontend/src/embedding-sdk-package/data-app-query-sync/metabase-client.ts
2026-10-05 16:11:30 +00:00

235 lines
6.2 KiB
TypeScript

import type {
DataAppMetadata,
DataAppMetric,
MetabaseAction,
MetabaseCard,
} from "./types";
export class MetabaseApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
}
}
/**
* Resolves to `null` on a confirmed 404, so a caller can treat "gone" as
* recoverable. Any other failure still throws: only a 404 proves the entity is
* absent rather than unreachable.
*/
export const orNullOn404 = async <T>(
request: Promise<T>,
): Promise<T | null> => {
try {
return await request;
} catch (error) {
if (error instanceof MetabaseApiError && error.status === 404) {
return null;
}
throw error;
}
};
export class MetabaseClient {
constructor(
private readonly baseUrl: string,
private readonly apiKey: string,
) {}
private async request<T>(pathname: string, init?: RequestInit): Promise<T> {
const baseUrl = new URL(this.baseUrl);
baseUrl.pathname = `${baseUrl.pathname.replace(/\/?$/, "/")}api/${pathname.replace(/^\//, "")}`;
const requestUrl = baseUrl;
const method = (init?.method ?? "GET").toUpperCase();
const response = await fetch(requestUrl, {
...init,
signal: init?.signal ?? AbortSignal.timeout(60_000),
headers: {
"content-type": "application/json",
"x-api-key": this.apiKey,
...init?.headers,
},
});
if (!response.ok) {
const message = await response.text();
throw new MetabaseApiError(
response.status,
// eslint-disable-next-line metabase/no-literal-metabase-strings -- CLI protocol error names the remote service.
`Metabase returned ${response.status} for ${method} ${requestUrl}: ${message}`,
);
}
if (response.status === 204) {
// The generic return type is controlled by the caller for no-content requests.
return undefined as T;
}
const body: unknown = await response.json();
// The endpoint-specific methods define the trusted API response contract.
return body as T;
}
ensureDraft(slug: string) {
return this.request<DataAppMetadata>(
`apps/${encodeURIComponent(slug)}/draft`,
{ method: "POST" },
);
}
updateTableDependencies(slug: string, tableIds: number[]) {
return this.request<DataAppMetadata>(
`apps/${encodeURIComponent(slug)}/table-dependencies`,
{
method: "PUT",
body: JSON.stringify({ table_ids: tableIds }),
},
);
}
/**
* Resolves the tables that already-saved queries read. Only the server sees an
* implicit join: the table a foreign key reaches appears nowhere in the query,
* so walking its JSON would leave that table out of the app's dependencies.
*/
async resolveTableDependencies(
slug: string,
datasetQueries: ReadonlyArray<Record<string, unknown>>,
): Promise<number[]> {
if (datasetQueries.length === 0) {
return [];
}
const { table_ids } = await this.request<{ table_ids: number[] }>(
`apps/${encodeURIComponent(slug)}/query-table-dependencies`,
{
method: "POST",
body: JSON.stringify({ dataset_queries: datasetQueries }),
},
);
return table_ids;
}
async resolveQuery(slug: string, query: Record<string, unknown>) {
const resolved = await this.request<{
database_id: number;
dataset_query: Record<string, unknown>;
table_ids: number[];
metrics?: DataAppMetric[];
}>(`apps/${encodeURIComponent(slug)}/query`, {
method: "POST",
body: JSON.stringify({ stages: [query] }),
});
return { ...resolved, metrics: resolved.metrics ?? [] };
}
getCard(id: number) {
return this.request<MetabaseCard>(`card/${id}`);
}
createCard(input: {
name: string;
collectionId: number;
datasetQuery: Record<string, unknown>;
}) {
return this.request<MetabaseCard>("card", {
method: "POST",
body: JSON.stringify({
name: input.name,
type: "question",
dataset_query: input.datasetQuery,
display: "table",
visualization_settings: {},
collection_id: input.collectionId,
}),
});
}
updateCard(
id: number,
input: {
name: string;
collectionId: number;
datasetQuery: Record<string, unknown>;
},
) {
return this.request<MetabaseCard>(`card/${id}`, {
method: "PUT",
body: JSON.stringify({
name: input.name,
type: "question",
dataset_query: input.datasetQuery,
collection_id: input.collectionId,
archived: false,
}),
});
}
moveCardToCollection(id: number, collectionId: number) {
return this.request<MetabaseCard>(`card/${id}`, {
method: "PUT",
body: JSON.stringify({ collection_id: collectionId }),
});
}
deleteCard(id: number) {
return this.request<void>(`card/${id}`, { method: "DELETE" });
}
createMetric(input: MetricInput) {
return this.request<MetabaseCard>("card", {
method: "POST",
body: JSON.stringify(metricBody(input)),
});
}
updateMetric(id: number, input: MetricInput) {
return this.request<MetabaseCard>(`card/${id}`, {
method: "PUT",
body: JSON.stringify(metricBody(input)),
});
}
getAction(id: number) {
return this.request<MetabaseAction>(`action/${id}`);
}
createAction(body: Record<string, unknown>) {
return this.request<MetabaseAction>("action", {
method: "POST",
body: JSON.stringify(body),
});
}
updateAction(id: number, body: Record<string, unknown>) {
return this.request<MetabaseAction>(`action/${id}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
deleteAction(id: number) {
return this.request<void>(`action/${id}`, { method: "DELETE" });
}
}
interface MetricInput {
name: string;
collectionId: number;
datasetQuery: Record<string, unknown>;
display: string;
visualizationSettings: Record<string, unknown>;
description: string | null;
}
const metricBody = (input: MetricInput) => ({
name: input.name,
type: "metric",
archived: false,
dataset_query: input.datasetQuery,
display: input.display,
visualization_settings: input.visualizationSettings,
description: input.description,
collection_id: input.collectionId,
});