Compare commits

...

4 Commits

Author SHA1 Message Date
Romeo Van Snick
5d0da527fb Route param-remapping URLs through the embed middleware
`api/card.ts` and `api/dashboard.ts` built the parameter-value remapping URL via
`PLUGIN_API.getRemapped*Url`, whose embed override (`setupRemappingUrls`)
produced the `/api/embed/.../:entityIdentifier/...` shape — the last reason these
endpoint files imported `metabase/plugins`.

Drop the getter and have the endpoints use the same `:tag` template + params
convention the sibling `values`/`search` endpoints already use, so the existing
embed override middleware (which already has remapping transforms) does the
rewrite. Embed requests pass `entityIdentifier` instead of the real id, mirroring
`parameters/actions` — non-embed fills `:cardId`/`:dashId`, embed swaps to
`:entityIdentifier` and the guest handler injects the token. `substituteUrlTags`
encodes tag values, matching the getter's prior `encodeURIComponent`.

Removes `getRemapped*Url` + `setupRemappingUrls` and the now-empty `PLUGIN_API`;
`metabase/api` no longer imports `metabase/plugins` or `metabase/embedding-sdk`.

NOTE: verify the embedding e2e suite (guest/public/static parameter remapping)
before merge — this changes outward-facing embed request URLs and is not covered
by unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:56:17 -07:00
Romeo Van Snick
8e8fbd0302 Invert the embedded-app header check off metabase/api
`client.ts` imported `isEmbeddingSdk` from `metabase/embedding-sdk/config` to
decide whether to send the `X-Metabase-Embedded` header — the last SDK import in
the api HTTP client. Replace it with an `ApiClient.isEmbeddingSdk` field (default
`false`) read per-request, mirroring the existing `apiKey`/`sessionToken`/
`requestClient` inversion: feature code sets it, the client only reads it.

The SDK runtimes that issue requests through the shared client set the field
alongside the existing `EMBEDDING_SDK_CONFIG.isEmbeddingSdk = true` (SDK bundle,
simple-embedding and MCP app entries, and the jest SDK setup). `metabase/api/client`
now imports nothing from `metabase/embedding-sdk`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:58:02 -07:00
Romeo Van Snick
bdfb02b23e Register all request middleware through a single named registry
Previously `apiRequestManipulationMiddleware` hardcoded five embed/SDK plugin
"slots" (no-op-by-default functions reassigned in place) and ran them ahead of a
separate `api.beforeRequestHandlers` array that other features pushed onto. Two
registration mechanisms, an ordering baked into `metabase/api`, a dead
`overrideRequestsForStaticEmbeds` slot, and `.includes()` dedup scattered across
call sites.

Replace both with one module-level registry in `api/client/middleware`:

- `registerOnBeforeRequestHandler(name, fn)` — register under a stable name.
  Re-registering a name replaces the handler in place (keeping its run-order
  position), so a flow that rebuilds its handler on re-init (the SDK session
  refresh, which closes over a fresh dispatch/config) swaps in the latest
  version without duplicating or reordering. The name doubles as a debug label.
- `applyOnBeforeRequestHandlers(config)` runs the registry in registration
  order; `clearOnBeforeRequestHandlers()` resets it (used by plugin
  reinitialize and tests); `getOnBeforeRequestHandlerNames()` aids debugging.

`metabase/api` no longer knows which handlers exist or in what order — each
feature registers from its own init flow in dependency order:

- SDK session refresh (`auth.ts`), guest session refresh + override + preview
  rewrite (`guest-embed.ts` / `override-requests-for-embeds.ts`), public/static
  override + preview rewrite (`use-public-endpoints.tsx`), and the two embed
  referrer header handlers.

Because run order now follows registration order, the public/static flow
registers its override before the preview rewrite (both synchronously during
render, so they still catch the first embed request). Drops the dead static
slot, the no-op plugin defaults, and the per-call-site dedup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:51:16 -07:00
Romeo Van Snick
af860693fd Move OnBeforeRequest* types into the api client middleware module
The `OnBeforeRequestHandler` / `OnBeforeRequestHandlerConfig` types lived in
`metabase/plugins/oss/api` but describe the request-manipulation middleware,
which lives under `metabase/api/client`. Relocate the type definitions to
`metabase/api/client/middleware` and re-export them from the `metabase/api/client`
barrel, so the middleware contract is fully contained in its own module. Pure
move plus import updates; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:38:29 -07:00
22 changed files with 460 additions and 388 deletions

View File

@@ -28,7 +28,7 @@ import { getBuildInfo } from "embedding-sdk-shared/lib/get-build-info";
import { getWindow } from "embedding-sdk-shared/lib/get-window";
import type { SdkAuthState } from "embedding-sdk-shared/types/auth-state";
import { SDK_AUTH_STATE_KEY } from "embedding-sdk-shared/types/auth-state";
import { api } from "metabase/api/client";
import { api, registerOnBeforeRequestHandler } from "metabase/api/client";
import { requestSessionTokenFromEmbedJs } from "metabase/embedding/embedding-iframe-sdk/utils";
import {
EMBEDDING_SDK_IFRAME_EMBEDDING_CONFIG,
@@ -37,7 +37,6 @@ import {
} from "metabase/embedding-sdk/config";
import { samlTokenStorage } from "metabase/embedding-sdk/lib/saml-token-storage";
import type { MetabaseEmbeddingSessionToken } from "metabase/embedding-sdk/types/refresh-token";
import { PLUGIN_EMBEDDING_SDK } from "metabase/plugins";
import { loadSettings, refreshSiteSettings } from "metabase/redux/settings";
import { refreshCurrentUser } from "metabase/redux/user";
import { createAsyncThunk } from "metabase/redux/utils";
@@ -97,15 +96,14 @@ PLUGIN_EMBEDDING_SDK_AUTH.initAuth = async (
MetabaseSettings.setAll(authState.siteSettings as Settings);
// Set up the refresh handler so API calls can renew the token later.
PLUGIN_EMBEDDING_SDK.onBeforeRequestHandlers.getOrRefreshSessionHandler =
async () => {
const session = await dispatch(
getOrRefreshSession(authConfig),
).unwrap();
if (session?.id) {
api.sessionToken = session.id;
}
};
registerOnBeforeRequestHandler("sdk-session-refresh", async () => {
const session = await dispatch(
getOrRefreshSession(authConfig),
).unwrap();
if (session?.id) {
api.sessionToken = session.id;
}
});
return;
}
@@ -131,15 +129,12 @@ PLUGIN_EMBEDDING_SDK_AUTH.initAuth = async (
// Use existing user session. Do nothing.
} else if (isValidInstanceUrl) {
// SSO setup
PLUGIN_EMBEDDING_SDK.onBeforeRequestHandlers.getOrRefreshSessionHandler =
async () => {
const session = await dispatch(
getOrRefreshSession(authConfig),
).unwrap();
if (session?.id) {
api.sessionToken = session.id;
}
};
registerOnBeforeRequestHandler("sdk-session-refresh", async () => {
const session = await dispatch(getOrRefreshSession(authConfig)).unwrap();
if (session?.id) {
api.sessionToken = session.id;
}
});
try {
// verify that the session is actually valid before proceeding
await dispatch(getOrRefreshSession(authConfig)).unwrap();

View File

@@ -16,13 +16,16 @@ import { useLazySelector } from "embedding-sdk-shared/hooks/use-lazy-selector";
import { useMetabaseProviderPropsStore } from "embedding-sdk-shared/hooks/use-metabase-provider-props-store";
import { ensureMetabaseProviderPropsStore } from "embedding-sdk-shared/lib/ensure-metabase-provider-props-store";
import { getBuildInfo } from "embedding-sdk-shared/lib/get-build-info";
import { api } from "metabase/api/client";
import {
type OnBeforeRequestHandlerConfig,
api,
registerOnBeforeRequestHandler,
} from "metabase/api/client";
import registerDashboardVisualizations from "metabase/dashboard/visualizations/register";
import {
EMBEDDING_SDK_CONFIG,
isEmbeddingEajs,
} from "metabase/embedding-sdk/config";
import type { OnBeforeRequestHandlerConfig } from "metabase/plugins/oss/api";
import registerVisualizations from "metabase/visualizations/register";
const reactSdkEmbedReferrerHandler = async (
@@ -119,16 +122,16 @@ export const useInitDataInternal = ({
// For the React SDK, send the host page URL as the embed referrer in a
// header on every request. The EAJS iframe registers its own handler in
// SdkIframeEmbedRoute.tsx using the value received via postMessage.
if (
!isEmbeddingEajs() &&
!api.beforeRequestHandlers.includes(reactSdkEmbedReferrerHandler)
) {
api.beforeRequestHandlers.push(reactSdkEmbedReferrerHandler);
// Registration is idempotent (keyed by name).
if (!isEmbeddingEajs()) {
registerOnBeforeRequestHandler(
"react-sdk-embed-referrer",
reactSdkEmbedReferrerHandler,
);
}
// Dedupe by handler identity (matches the `beforeRequestHandlers` pattern
// above) rather than total listener count — other code can register its own
// `responseError` listeners without disabling ours.
// Dedupe by handler identity rather than total listener count — other code
// can register its own `responseError` listeners without disabling ours.
if (!api.listeners("responseError").includes(sdkResponseErrorHandler)) {
api.on("responseError", sdkResponseErrorHandler);
}

View File

@@ -1,10 +1,14 @@
/* eslint-disable import/order */
import { api } from "metabase/api/client";
import { EMBEDDING_SDK_CONFIG } from "metabase/embedding-sdk/config";
// Enable SDK mode as we are in the SDK bundle
// This applies to SDK derivatives such as new iframe embedding.
EMBEDDING_SDK_CONFIG.isEmbeddingSdk = true;
// Mirror the flag onto the api client so it can omit the embedded-app header
// without `metabase/api` importing SDK config.
api.isEmbeddingSdk = true;
// Import the embedding SDK vendors side-effects
import "metabase/embedding-sdk/vendors-side-effects";

View File

@@ -1,9 +1,11 @@
import { merge } from "icepick";
import type { MetabaseAuthConfig } from "embedding-sdk-bundle/types/auth-config";
import {
type OnBeforeRequestHandlerConfig,
registerOnBeforeRequestHandler,
} from "metabase/api/client";
import { overrideRequestsForGuestEmbeds } from "metabase/embedding/lib/override-requests-for-embeds";
import { PLUGIN_EMBEDDING_SDK } from "metabase/plugins";
import type { OnBeforeRequestHandlerConfig } from "metabase/plugins/oss/api";
import { refreshSiteSettings } from "metabase/redux/settings";
import { createAsyncThunk } from "metabase/redux/utils";
import { isJWT } from "metabase/utils/jwt";
@@ -13,11 +15,13 @@ import { getOrRefreshGuestSession } from "./auth";
export const initGuestEmbed = createAsyncThunk<void, MetabaseAuthConfig>(
"sdk/token/INIT_GUEST_EMBED",
async (authConfig: MetabaseAuthConfig, { dispatch }) => {
overrideRequestsForGuestEmbeds();
if (authConfig.isGuest && authConfig.guestEmbedProviderUri) {
// Replaces the request token with the newly refreshed guest embed token.
PLUGIN_EMBEDDING_SDK.onBeforeRequestHandlers.getOrRefreshGuestSessionHandler =
// Registered before the embed override (below) so the token is refreshed
// before the override's URL `:tag` substitution reads it — handlers run in
// registration order.
registerOnBeforeRequestHandler(
"sdk-guest-session-refresh",
async (config: OnBeforeRequestHandlerConfig) => {
const newToken = await dispatch(
getOrRefreshGuestSession(authConfig),
@@ -67,9 +71,12 @@ export const initGuestEmbed = createAsyncThunk<void, MetabaseAuthConfig>(
});
}
}
};
},
);
}
overrideRequestsForGuestEmbeds();
await dispatch(refreshSiteSettings());
},
);

View File

@@ -1,5 +1,7 @@
import { api } from "metabase/api/client";
import { EMBEDDING_SDK_CONFIG } from "metabase/embedding-sdk/config";
process.env.IS_EMBEDDING_SDK = "true";
EMBEDDING_SDK_CONFIG.isEmbeddingSdk = true;
api.isEmbeddingSdk = true;

View File

@@ -1,4 +1,3 @@
import { PLUGIN_API } from "metabase/plugins";
import { QueryMetadataSchema, QuestionSchema } from "metabase/schema";
import type {
Card,
@@ -153,13 +152,18 @@ export const cardApi = Api.injectEndpoints({
FieldValue,
GetRemappedCardParameterValueRequest
>({
query: ({ card_id, parameter_id, ...params }) => ({
query: ({ card_id, entityIdentifier, parameter_id, ...params }) => ({
method: "GET",
url: PLUGIN_API.getRemappedCardParameterValueUrl(
card_id,
parameter_id,
),
params,
url: "/api/card/:cardId/params/:paramId/remapping",
// Embed requests carry an entity identifier (uuid/token) instead of
// the real card id; the embed override rewrites `:cardId` →
// `:entityIdentifier` (see override-requests-for-embeds). Mirrors the
// `cardId`/`entityIdentifier` switch in `parameters/actions`.
params: {
...params,
paramId: parameter_id,
...(entityIdentifier ? { entityIdentifier } : { cardId: card_id }),
},
}),
providesTags: (_response, _error, { parameter_id }) =>
provideParameterValuesTags(parameter_id),

View File

@@ -1,11 +1,6 @@
/* eslint-disable metabase/no-literal-metabase-strings */
import EventEmitter from "events";
import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
import type {
OnBeforeRequestHandler,
OnBeforeRequestHandlerConfig,
} from "metabase/plugins/oss/api";
import { IFRAMED_IN_SELF, isWithinIframe } from "metabase/utils/iframe";
import { getTraceparentHeader } from "metabase/utils/otel";
import { retry } from "metabase/utils/retry";
@@ -14,7 +9,10 @@ import { addAntiCsrfToken, updateAntiCsrfToken } from "./csrf";
import { NetworkError, isRetriableError } from "./errors";
import { getLocaleHeader } from "./locale";
import { type RequestMethod, isRequestMethod } from "./method";
import { apiRequestManipulationMiddleware } from "./middleware";
import {
type OnBeforeRequestHandlerConfig,
applyOnBeforeRequestHandlers,
} from "./middleware";
import type {
EventMap,
RequestClientInfo,
@@ -36,8 +34,10 @@ export class ApiClient extends EventEmitter<EventMap> {
apiKey = "";
sessionToken: string | undefined;
requestClient: RequestClientInfo | undefined;
beforeRequestHandlers: OnBeforeRequestHandler[] = [];
// Set to `true` by the embedding SDK entry points. Read here (rather than
// importing `isEmbeddingSdk`) so `metabase/api` stays free of SDK imports —
// the same inversion as `apiKey`/`sessionToken`/`requestClient`.
isEmbeddingSdk = false;
private buildUrl(
template: string,
@@ -64,7 +64,7 @@ export class ApiClient extends EventEmitter<EventMap> {
headers["X-Metabase-Session"] = this.sessionToken;
}
if (isWithinIframe() && !isEmbeddingSdk()) {
if (isWithinIframe() && !this.isEmbeddingSdk) {
headers["X-Metabase-Embedded"] = "true";
}
@@ -99,20 +99,17 @@ export class ApiClient extends EventEmitter<EventMap> {
}
private async _resolveOptions(options: OnBeforeRequestHandlerConfig) {
const middlewareResult = await apiRequestManipulationMiddleware(
this.beforeRequestHandlers,
{
...options,
// `headers` is optional on the public API, but handlers may merge into
// it, so always hand the pipeline a real object to spread into.
headers: options.headers ?? {},
// This will transform arrays to objects with numeric keys
// we shouldn't be using top level-arrays in the API.
// Both bags are defensively copied so middleware can safely mutate them.
data: { ...options.data },
body: options.body ? { ...options.body } : undefined,
},
);
const middlewareResult = await applyOnBeforeRequestHandlers({
...options,
// `headers` is optional on the public API, but handlers may merge into
// it, so always hand the pipeline a real object to spread into.
headers: options.headers ?? {},
// This will transform arrays to objects with numeric keys
// we shouldn't be using top level-arrays in the API.
// Both bags are defensively copied so middleware can safely mutate them.
data: { ...options.data },
body: options.body ? { ...options.body } : undefined,
});
return {
...middlewareResult,

View File

@@ -1,6 +1,10 @@
import fetchMock from "fetch-mock";
import { ApiClient } from "./client";
import {
clearOnBeforeRequestHandlers,
registerOnBeforeRequestHandler,
} from "./middleware";
describe("api", () => {
describe("request (RTK entry point)", () => {
@@ -12,6 +16,7 @@ describe("api", () => {
afterEach(() => {
fetchMock.removeRoutes().clearHistory();
clearOnBeforeRequestHandlers();
});
it("`:tag*` substitutes a multi-segment value without URL-encoding the slashes", async () => {
@@ -164,7 +169,7 @@ describe("api", () => {
// from the body field — not just from URL params.
fetchMock.get("path:/api/embed/card/SOME_JWT/query", { rows: [] });
apiInstance.beforeRequestHandlers.push(async (config) => {
registerOnBeforeRequestHandler("embed-override", async (config) => {
if (config.url === "/api/card/:cardId/query") {
return {
...config,

View File

@@ -1,5 +1,14 @@
export * from "./client";
export * from "./method";
export * from "./errors";
export type {
OnBeforeRequestHandler,
OnBeforeRequestHandlerConfig,
} from "./middleware";
export {
clearOnBeforeRequestHandlers,
getOnBeforeRequestHandlerNames,
registerOnBeforeRequestHandler,
} from "./middleware";
export type { RequestOptions } from "./types";
export { setLocaleHeader } from "./locale";

View File

@@ -1,33 +1,84 @@
import { PLUGIN_API, PLUGIN_EMBEDDING_SDK } from "metabase/plugins";
import type {
OnBeforeRequestHandler,
OnBeforeRequestHandlerConfig,
} from "metabase/plugins/oss/api";
import type { RequestMethod } from "./method";
export async function apiRequestManipulationMiddleware(
beforeRequestHandlers: OnBeforeRequestHandler[],
export type OnBeforeRequestHandlerConfig = {
method: RequestMethod;
url: string;
headers?: Record<string, string>;
// URL `:tag` params (and querystring leftovers). For the legacy GET/POST
// helpers this holds the whole request bag.
data: Record<string, unknown>;
// The JSON-body bag, kept as a separate channel from `data`. Exposed to
// handlers so embed URL `:tag`s — notably the guest-embed `:token` — can be
// filled from body fields, and so the refresh handler can swap a stale body
// token. `undefined` for GETs, raw (FormData/URLSearchParams) bodies, and the
// legacy helpers (which pack everything into `data`).
body?: Record<string, unknown>;
};
export type OnBeforeRequestHandler = (
data: OnBeforeRequestHandlerConfig,
) => Promise<void | Partial<OnBeforeRequestHandlerConfig>>;
type RegisteredHandler = {
name: string;
handler: OnBeforeRequestHandler;
};
// The single source of truth for request-manipulation handlers. Handlers run in
// registration order (see `applyOnBeforeRequestHandlers`), so callers register
// in dependency order — there is no separate priority mechanism. A feature
// registers its handlers from its own init flow rather than this module
// hardcoding which handlers exist, so nothing here needs to know about embeds,
// the SDK, or any other consumer.
const registeredHandlers: RegisteredHandler[] = [];
/**
* Register a request handler under a stable `name`.
*
* Re-registering an existing `name` replaces the handler in place, keeping its
* position in the run order. This lets a flow that rebuilds its handler on
* re-init — e.g. the SDK session refresh, which closes over a fresh
* `dispatch`/`authConfig` each time — swap in the latest version without
* duplicating or reordering. The `name` doubles as a debug label for tracing
* why an endpoint was rewritten.
*/
export function registerOnBeforeRequestHandler(
name: string,
handler: OnBeforeRequestHandler,
) {
const existing = registeredHandlers.find((entry) => entry.name === name);
if (existing) {
existing.handler = handler;
return;
}
registeredHandlers.push({ name, handler });
}
/** Names of the registered handlers, in run order. For debugging and tests. */
export function getOnBeforeRequestHandlerNames(): string[] {
return registeredHandlers.map((entry) => entry.name);
}
/** Drop all registered handlers. Used by tests and the plugin reinitialize. */
export function clearOnBeforeRequestHandlers() {
registeredHandlers.length = 0;
}
/**
* Run every registered handler over the request config, in registration order.
* Each handler sees the result of the previous one and may return a partial
* override that is merged in.
*/
export async function applyOnBeforeRequestHandlers(
requestConfig: OnBeforeRequestHandlerConfig,
): Promise<OnBeforeRequestHandlerConfig> {
// Handlers order is important.
// Handlers are executed in order and each handler uses the data returned by a previous handler.
const handlers = [
PLUGIN_EMBEDDING_SDK.onBeforeRequestHandlers.getOrRefreshSessionHandler,
PLUGIN_EMBEDDING_SDK.onBeforeRequestHandlers
.getOrRefreshGuestSessionHandler,
PLUGIN_EMBEDDING_SDK.onBeforeRequestHandlers.overrideRequestsForGuestEmbeds,
PLUGIN_API.onBeforeRequestHandlers.overrideRequestsForPublicEmbeds,
PLUGIN_API.onBeforeRequestHandlers.overrideRequestsForStaticEmbeds,
...beforeRequestHandlers,
];
let result = requestConfig;
for (const handler of handlers) {
for (const { handler } of registeredHandlers) {
const next = await handler(result);
if (next) {
result = merge(result, next);
}
}
return result;
}

View File

@@ -1,190 +1,241 @@
import type { OnBeforeRequestHandlerConfig } from "metabase/plugins/oss/api";
import type { OnBeforeRequestHandlerConfig } from "metabase/api/client";
import { apiRequestManipulationMiddleware } from "./middleware";
import {
applyOnBeforeRequestHandlers,
clearOnBeforeRequestHandlers,
getOnBeforeRequestHandlerNames,
registerOnBeforeRequestHandler,
} from "./middleware";
describe("apiRequestManipulationMiddleware", () => {
it("should return the original data when there are no handlers", async () => {
const inputData = {
method: "GET" as const,
url: "/api/test",
headers: {},
data: {},
};
const result = await apiRequestManipulationMiddleware([], inputData);
expect(result).toEqual(inputData);
describe("on-before-request handler registry", () => {
afterEach(() => {
clearOnBeforeRequestHandlers();
});
it("should return the original data when handler returns void", async () => {
const inputData = {
method: "POST" as const,
url: "/api/test",
headers: { "X-Test": "value" },
data: {},
};
describe("applyOnBeforeRequestHandlers", () => {
it("should return the original data when there are no handlers", async () => {
const inputData = {
method: "GET" as const,
url: "/api/test",
headers: {},
data: {},
};
// Mock a handler that returns void
const handler = jest.fn(async function () {
return;
const result = await applyOnBeforeRequestHandlers(inputData);
expect(result).toEqual(inputData);
});
const result = await apiRequestManipulationMiddleware([handler], inputData);
it("should return the original data when handler returns void", async () => {
const inputData = {
method: "POST" as const,
url: "/api/test",
headers: { "X-Test": "value" },
data: {},
};
expect(result).toEqual(inputData);
expect(handler).toHaveBeenCalledWith(inputData);
});
const handler = jest.fn(async function () {
return;
});
registerOnBeforeRequestHandler("handler", handler);
it("should update only the url when handler returns partial modification", async () => {
const inputData = {
method: "GET" as const,
url: "/api/original",
headers: {},
data: {},
};
const result = await applyOnBeforeRequestHandlers(inputData);
const expectedUrl = "/api/modified";
expect(result).toEqual(inputData);
expect(handler).toHaveBeenCalledWith(inputData);
});
const handler = jest.fn(async function () {
// Simulate handler that only modifies URL
return {
it("should update only the url when handler returns partial modification", async () => {
const inputData = {
method: "GET" as const,
url: "/api/original",
headers: {},
data: {},
};
const expectedUrl = "/api/modified";
registerOnBeforeRequestHandler("handler", async function () {
// Simulate handler that only modifies URL
return {
url: expectedUrl,
};
});
const result = await applyOnBeforeRequestHandlers(inputData);
expect(result).toEqual({
...inputData,
url: expectedUrl,
});
});
it("should update method, url, and headers when handler returns full modification", async () => {
const inputData = {
method: "GET" as const,
url: "/api/original",
headers: {},
data: {},
};
const modifications = {
method: "POST" as const,
url: "/api/modified",
headers: { "X-Custom": "value" },
};
registerOnBeforeRequestHandler("handler", async function () {
return modifications;
});
const result = await applyOnBeforeRequestHandlers(inputData);
expect(result).toEqual({ ...inputData, ...modifications });
});
const result = await apiRequestManipulationMiddleware([handler], inputData);
it("should merge headers properly when handler returns partial headers", async () => {
const inputData = {
method: "POST" as const,
url: "/api/test",
headers: { "X-Original": "value" },
data: {},
};
expect(result).toEqual({
...inputData,
url: expectedUrl,
});
});
const newHeaders = { "X-Modified": "new-value" };
it("should update method, url, and headers when handler returns full modification", async () => {
const inputData = {
method: "GET" as const,
url: "/api/original",
headers: {},
data: {},
};
const modifications = {
method: "POST" as const,
url: "/api/modified",
headers: { "X-Custom": "value" },
};
const handler = jest.fn(async function () {
return modifications;
});
const result = await apiRequestManipulationMiddleware([handler], inputData);
expect(result).toEqual({ ...inputData, ...modifications });
});
it("should merge headers properly when handler returns partial headers", async () => {
const inputData = {
method: "POST" as const,
url: "/api/test",
headers: { "X-Original": "value" },
data: {},
};
const newHeaders = { "X-Modified": "new-value" };
const handler = jest.fn(async function (
data: OnBeforeRequestHandlerConfig,
) {
return {
headers: {
...data.headers,
...newHeaders,
registerOnBeforeRequestHandler(
"handler",
async function (data: OnBeforeRequestHandlerConfig) {
return {
headers: {
...data.headers,
...newHeaders,
},
};
},
};
);
const result = await applyOnBeforeRequestHandlers(inputData);
expect(result.headers).toEqual({
"X-Original": "value",
"X-Modified": "new-value",
});
});
const result = await apiRequestManipulationMiddleware([handler], inputData);
it("should execute multiple handlers in registration order", async () => {
const inputData = {
method: "GET" as const,
url: "/api/start",
headers: {},
data: { value: 0 },
};
expect(result.headers).toEqual({
"X-Original": "value",
"X-Modified": "new-value",
const executionOrder: number[] = [];
const handler1 = jest.fn(async (data: OnBeforeRequestHandlerConfig) => {
executionOrder.push(1);
return {
...data,
url: data.url + "/step1",
data: { ...data.data, value: (data.data.value as number) + 2 },
};
});
const handler2 = jest.fn(async (data: OnBeforeRequestHandlerConfig) => {
executionOrder.push(2);
return {
...data,
url: data.url + "/step2",
data: { ...data.data, value: (data.data.value as number) * 10 },
};
});
const handler3 = jest.fn(async (data: OnBeforeRequestHandlerConfig) => {
executionOrder.push(3);
return {
...data,
url: data.url + "/step3",
data: { ...data.data, value: (data.data.value as number) - 4 },
};
});
registerOnBeforeRequestHandler("handler1", handler1);
registerOnBeforeRequestHandler("handler2", handler2);
registerOnBeforeRequestHandler("handler3", handler3);
const result = await applyOnBeforeRequestHandlers(inputData);
expect(executionOrder).toEqual([1, 2, 3]);
expect(result.url).toBe("/api/start/step1/step2/step3");
expect(result.data.value).toBe(16); // = ((0 + 2) * 10) - 4
expect(handler1).toHaveBeenCalledWith(
expect.objectContaining({ url: "/api/start" }),
);
expect(handler2).toHaveBeenCalledWith(
expect.objectContaining({ url: "/api/start/step1" }),
);
expect(handler3).toHaveBeenCalledWith(
expect.objectContaining({ url: "/api/start/step1/step2" }),
);
});
it("should preserve the original headers and data when handler does not modify them", async () => {
const inputData = {
method: "POST" as const,
url: "/api/complex",
headers: { "X-Custom": "header" },
data: { keepMe: true },
};
registerOnBeforeRequestHandler("handler", async function () {
return {
url: "/api/modified-url",
};
});
const result = await applyOnBeforeRequestHandlers(inputData);
expect(result.url).toBe("/api/modified-url");
expect(result.headers).toEqual(inputData.headers);
expect(result.data).toEqual(inputData.data);
});
});
it("should execute multiple handlers in order", async () => {
describe("registerOnBeforeRequestHandler", () => {
const inputData = {
method: "GET" as const,
url: "/api/start",
url: "/api/test",
headers: {},
data: { value: 0 },
data: {},
};
const executionOrder: number[] = [];
it("deduplicates by name, keeping a single entry", () => {
registerOnBeforeRequestHandler("same-name", async () => {});
registerOnBeforeRequestHandler("same-name", async () => {});
const handler1 = jest.fn(async (data: OnBeforeRequestHandlerConfig) => {
executionOrder.push(1);
return {
...data,
url: data.url + "/step1",
data: { ...data.data, value: (data.data.value as number) + 2 },
};
expect(getOnBeforeRequestHandlerNames()).toEqual(["same-name"]);
});
const handler2 = jest.fn(async (data: OnBeforeRequestHandlerConfig) => {
executionOrder.push(2);
return {
...data,
url: data.url + "/step2",
data: { ...data.data, value: (data.data.value as number) * 10 },
};
it("replaces a handler registered under an existing name, in place", async () => {
registerOnBeforeRequestHandler("first", async () => ({
url: "/api/from-first",
}));
registerOnBeforeRequestHandler("second", async () => ({
headers: { "X-Second": "yes" },
}));
// Re-register "first" with a new implementation.
registerOnBeforeRequestHandler("first", async () => ({
url: "/api/from-replacement",
}));
// Order is preserved (replaced in place, not moved to the end)…
expect(getOnBeforeRequestHandlerNames()).toEqual(["first", "second"]);
// …and the latest implementation is the one that runs.
const result = await applyOnBeforeRequestHandlers(inputData);
expect(result.url).toBe("/api/from-replacement");
expect(result.headers).toEqual({ "X-Second": "yes" });
});
const handler3 = jest.fn(async (data: OnBeforeRequestHandlerConfig) => {
executionOrder.push(3);
return {
...data,
url: data.url + "/step3",
data: { ...data.data, value: (data.data.value as number) - 4 },
};
});
const result = await apiRequestManipulationMiddleware(
[handler1, handler2, handler3],
inputData,
);
expect(executionOrder).toEqual([1, 2, 3]);
expect(result.url).toBe("/api/start/step1/step2/step3");
expect(result.data.value).toBe(16); // = ((0 + 2) * 10) - 4
expect(handler1).toHaveBeenCalledWith(
expect.objectContaining({ url: "/api/start" }),
);
expect(handler2).toHaveBeenCalledWith(
expect.objectContaining({ url: "/api/start/step1" }),
);
expect(handler3).toHaveBeenCalledWith(
expect.objectContaining({ url: "/api/start/step1/step2" }),
);
});
it("should preserve the original headers and data when handler does not modify them", async () => {
const inputData = {
method: "POST" as const,
url: "/api/complex",
headers: { "X-Custom": "header" },
data: { keepMe: true },
};
const handler = jest.fn(async function () {
return {
url: "/api/modified-url",
};
});
const result = await apiRequestManipulationMiddleware([handler], inputData);
expect(result.url).toBe("/api/modified-url");
expect(result.headers).toEqual(inputData.headers);
expect(result.data).toEqual(inputData.data);
});
});

View File

@@ -1,4 +1,3 @@
import { PLUGIN_API } from "metabase/plugins";
import { DashboardSchema, QueryMetadataSchema } from "metabase/schema";
import type {
CopyDashboardRequest,
@@ -140,13 +139,25 @@ export const dashboardApi = Api.injectEndpoints({
FieldValue,
GetRemappedDashboardParameterValueRequest
>({
query: ({ dashboard_id, parameter_id, ...params }) => ({
query: ({
dashboard_id,
entityIdentifier,
parameter_id,
...params
}) => ({
method: "GET",
url: PLUGIN_API.getRemappedDashboardParameterValueUrl(
dashboard_id,
parameter_id,
),
params,
url: "/api/dashboard/:dashId/params/:paramId/remapping",
// Embed requests carry an entity identifier (uuid/token) instead of
// the real dashboard id; the embed override rewrites `:dashId` →
// `:entityIdentifier` (see override-requests-for-embeds). Mirrors the
// `dashId`/`entityIdentifier` switch in `parameters/actions`.
params: {
...params,
paramId: parameter_id,
...(entityIdentifier
? { entityIdentifier }
: { dashId: dashboard_id }),
},
}),
providesTags: (_response, _error, { parameter_id }) =>
provideParameterValuesTags(parameter_id),

View File

@@ -1,12 +1,15 @@
import fetchMock from "fetch-mock";
import { api } from "./client";
import {
clearOnBeforeRequestHandlers,
registerOnBeforeRequestHandler,
} from "./client";
import { GET, POST } from "./legacy-client";
describe("legacy-client", () => {
afterEach(() => {
fetchMock.removeRoutes().clearHistory();
api.beforeRequestHandlers = [];
clearOnBeforeRequestHandlers();
});
it("substitutes URL :tags from rawData and routes the remainder to the JSON body for POST", async () => {
@@ -69,7 +72,7 @@ describe("legacy-client", () => {
// (body-shaped) `token` field of the bag — not just from URL params.
fetchMock.get("path:/api/embed/card/SOME_JWT/query", { rows: [] });
api.beforeRequestHandlers.push(async (config) => {
registerOnBeforeRequestHandler("embed-override", async (config) => {
if (config.url === "/api/card/:cardId/query") {
return {
...config,

View File

@@ -11,6 +11,7 @@ import { EMBEDDING_SDK_CONFIG } from "metabase/embedding-sdk/config";
import "sdk-iframe-embedding-ee-plugins";
EMBEDDING_SDK_CONFIG.isEmbeddingSdk = true;
api.isEmbeddingSdk = true;
EMBEDDING_SDK_CONFIG.isMcpApp = true;
EMBEDDING_SDK_CONFIG.metabaseClientRequestHeader = "mcp-apps";
EMBEDDING_SDK_CONFIG.tokenFeatureKey = "embedding_simple";

View File

@@ -5,6 +5,8 @@ import "metabase/utils/csp";
// Import the embedding SDK vendors side-effects
import "metabase/embedding-sdk/vendors-side-effects";
import { api } from "metabase/api/client";
// eslint-disable-next-line import/order
import {
EMBEDDING_SDK_CONFIG,
@@ -16,6 +18,7 @@ import {
*/
EMBEDDING_SDK_IFRAME_EMBEDDING_CONFIG.isSimpleEmbedding = true;
EMBEDDING_SDK_CONFIG.isEmbeddingSdk = true;
api.isEmbeddingSdk = true;
EMBEDDING_SDK_CONFIG.metabaseClientRequestHeader = "embedding-simple";
EMBEDDING_SDK_CONFIG.enableEmbeddingSettingKey = "enable-embedding-simple";
EMBEDDING_SDK_CONFIG.tokenFeatureKey = "embedding_simple";

View File

@@ -20,11 +20,13 @@ import type { SdkDashboardEntityPublicProps } from "embedding-sdk-bundle/types/d
import type { SdkQuestionEntityPublicProps } from "embedding-sdk-bundle/types/question";
import { applyThemePreset } from "embedding-sdk-shared/lib/apply-theme-preset";
import { createSnowplowTracker } from "metabase/analytics";
import { api } from "metabase/api/client";
import {
type OnBeforeRequestHandlerConfig,
registerOnBeforeRequestHandler,
} from "metabase/api/client";
import { EmbeddingFooter } from "metabase/embedding/components/EmbeddingFooter/EmbeddingFooter";
import { EMBEDDING_SDK_IFRAME_EMBEDDING_CONFIG } from "metabase/embedding-sdk/config";
import { PLUGIN_EMBEDDING_IFRAME_SDK } from "metabase/plugins";
import type { OnBeforeRequestHandlerConfig } from "metabase/plugins/oss/api";
import { useSelector } from "metabase/redux";
import { getSetting } from "metabase/selectors/settings";
import { getUserId } from "metabase/selectors/user";
@@ -62,10 +64,8 @@ const embedReferrerHandler = async (
}
};
// Register once — uses a named function ref so it can't be pushed twice
if (!api.beforeRequestHandlers.includes(embedReferrerHandler)) {
api.beforeRequestHandlers.push(embedReferrerHandler);
}
// Register once — idempotent by name.
registerOnBeforeRequestHandler("iframe-embed-referrer", embedReferrerHandler);
const onSettingsChanged = (settings: SdkIframeEmbedSettings) => {
// Tell the SDK whether to use the existing user session or not.

View File

@@ -1,13 +1,11 @@
import { sessionPropertiesPath } from "metabase/api";
import { type RequestMethod, api } from "metabase/api/client";
import { isEmbedPreview } from "metabase/embedding/config";
import {
PLUGIN_API,
PLUGIN_CONTENT_TRANSLATION,
PLUGIN_EMBEDDING_SDK,
} from "metabase/plugins";
import type { OnBeforeRequestHandlerConfig } from "metabase/plugins/oss/api";
import type { CardId, DashboardId, ParameterId } from "metabase-types/api";
type OnBeforeRequestHandlerConfig,
type RequestMethod,
registerOnBeforeRequestHandler,
} from "metabase/api/client";
import { isEmbedPreview } from "metabase/embedding/config";
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
type EmbedType = "guest" | "static" | "public";
@@ -208,22 +206,6 @@ export const overrideRequests = async ({
};
};
const setupRemappingUrls = (embedType: EmbedType) => {
const baseUrl = getBaseUrlByEmbedType(embedType);
PLUGIN_API.getRemappedDashboardParameterValueUrl = (
_dashboardId: DashboardId | undefined,
parameterId: ParameterId,
) =>
`${baseUrl}/dashboard/:entityIdentifier/params/${encodeURIComponent(parameterId)}/remapping`;
PLUGIN_API.getRemappedCardParameterValueUrl = (
_cardId: CardId | string | undefined,
parameterId: ParameterId,
) =>
`${baseUrl}/card/:entityIdentifier/params/${encodeURIComponent(parameterId)}/remapping`;
};
const EMBED_API_BASE_PATTERN = /^\/api\/embed(?=\/|$)/;
const EMBED_PREVIEW_API_BASE = "/api/preview_embed";
@@ -242,51 +224,46 @@ export const rewriteEmbedPreviewUrl = async ({
};
/**
* Registers the embed-preview rewrite on the shared client. It runs after the
* embed override handlers, so it covers both the override-produced
* `/api/embed/...` urls and the embed endpoints called directly (e.g.
* `EmbedApi`, `embedApi`).
* Registers the embed-preview rewrite on the shared client. It must run *after*
* the embed override handler, so the embed flows register it after
* `embed-request-override` — the registry runs handlers in registration order.
* This covers both the override-produced `/api/embed/...` urls and the embed
* endpoints called directly (e.g. `EmbedApi`, `embedApi`).
*
* Idempotent, so call sites can register it at the earliest safe moment without
* worrying about duplicates. For public/static embeds it must run *before* the
* dashboard fetcher's effect (see `usePublicEndpoints`): React runs child
* effects before parent effects, so registering from a parent `useMount` would
* miss the very first embed request (the dashboard load).
* Idempotent (keyed by name), so call sites can register it at the earliest safe
* moment without worrying about duplicates. For public/static embeds it must run
* *before* the dashboard fetcher's effect (see `usePublicEndpoints`): React runs
* child effects before parent effects, so registering from a parent `useMount`
* would miss the very first embed request (the dashboard load).
*/
export const setupEmbedPreviewRewrite = () => {
if (!api.beforeRequestHandlers.includes(rewriteEmbedPreviewUrl)) {
api.beforeRequestHandlers.push(rewriteEmbedPreviewUrl);
}
registerOnBeforeRequestHandler(
"embed-preview-rewrite",
rewriteEmbedPreviewUrl,
);
};
/**
* Registers a request interceptor that transforms standard API requests
* into guest embeds API requests.
* Registers a request interceptor that transforms standard API requests into
* guest embeds API requests. The override is registered before the preview
* rewrite so it runs first (see `setupEmbedPreviewRewrite`).
*/
export const overrideRequestsForGuestEmbeds = () => {
setupRemappingUrls("guest");
registerOnBeforeRequestHandler("embed-request-override", (data) =>
overrideRequests({ ...data, embedType: "guest" }),
);
setupEmbedPreviewRewrite();
PLUGIN_EMBEDDING_SDK.onBeforeRequestHandlers.overrideRequestsForGuestEmbeds =
(data) =>
overrideRequests({
...data,
embedType: "guest",
});
};
/**
* Registers a request interceptor that transforms standard API requests
* into public or static embeds API requests.
* Registers a request interceptor that transforms standard API requests into
* public or static embeds API requests. Callers that also need the preview
* rewrite must register it after this (see `usePublicEndpoints`).
*/
export const overrideRequestsForPublicOrStaticEmbeds = (
embedType: "static" | "public",
) => {
setupRemappingUrls(embedType);
PLUGIN_API.onBeforeRequestHandlers.overrideRequestsForPublicEmbeds = (data) =>
overrideRequests({
...data,
embedType,
});
registerOnBeforeRequestHandler("embed-request-override", (data) =>
overrideRequests({ ...data, embedType }),
);
};

View File

@@ -1,4 +1,7 @@
import { api } from "metabase/api/client";
import {
clearOnBeforeRequestHandlers,
getOnBeforeRequestHandlerNames,
} from "metabase/api/client";
import { isEmbedPreview } from "metabase/embedding/config";
import {
@@ -16,6 +19,7 @@ const mockIsEmbedPreview = jest.mocked(isEmbedPreview);
afterEach(() => {
mockIsEmbedPreview.mockReset();
clearOnBeforeRequestHandlers();
});
describe("matchUrlPattern", () => {
@@ -154,21 +158,18 @@ describe("setupEmbedPreviewRewrite", () => {
// The embed route registers `usePublicEndpoints` on a parent of the dashboard
// fetcher, and React runs child effects before parent effects, so this must be
// called synchronously (not from an effect) to catch the first embed request.
it("registers rewriteEmbedPreviewUrl on the shared client, idempotently", () => {
const before = api.beforeRequestHandlers.filter(
(handler) => handler === rewriteEmbedPreviewUrl,
).length;
it("registers the preview rewrite on the shared client, idempotently", () => {
const countPreviewHandlers = () =>
getOnBeforeRequestHandlerNames().filter(
(name) => name === "embed-preview-rewrite",
).length;
expect(before).toBe(0);
expect(countPreviewHandlers()).toBe(0);
setupEmbedPreviewRewrite();
setupEmbedPreviewRewrite();
const after = api.beforeRequestHandlers.filter(
(handler) => handler === rewriteEmbedPreviewUrl,
).length;
expect(after).toBe(1);
expect(countPreviewHandlers()).toBe(1);
});
});

View File

@@ -1,5 +1,4 @@
// Re-export all plugins from OSS modules (excluding reinitialize functions to avoid conflicts)
export { PLUGIN_API } from "./oss/api";
export {
PLUGIN_AUDIT,
type InsightsLinkProps,

View File

@@ -1,51 +1,10 @@
import type { RequestMethod } from "metabase/api/client";
import type { CardId, DashboardId, ParameterId } from "metabase-types/api";
export type OnBeforeRequestHandlerConfig = {
method: RequestMethod;
url: string;
headers?: Record<string, string>;
// URL `:tag` params (and querystring leftovers). For the legacy GET/POST
// helpers this holds the whole request bag.
data: Record<string, unknown>;
// The JSON-body bag, kept as a separate channel from `data`. Exposed to
// handlers so embed URL `:tag`s — notably the guest-embed `:token` — can be
// filled from body fields, and so the refresh handler can swap a stale body
// token. `undefined` for GETs, raw (FormData/URLSearchParams) bodies, and the
// legacy helpers (which pack everything into `data`).
body?: Record<string, unknown>;
};
export type OnBeforeRequestHandler = (
data: OnBeforeRequestHandlerConfig,
) => Promise<void | Partial<OnBeforeRequestHandlerConfig>>;
const getDefaultPluginApi = () => ({
onBeforeRequestHandlers: {
overrideRequestsForPublicEmbeds: async (
_data: OnBeforeRequestHandlerConfig,
): Promise<OnBeforeRequestHandlerConfig | void> => {},
overrideRequestsForStaticEmbeds: async (
_data: OnBeforeRequestHandlerConfig,
): Promise<OnBeforeRequestHandlerConfig | void> => {},
},
getRemappedCardParameterValueUrl: (
cardId: CardId | string | undefined,
parameterId: ParameterId,
) =>
`/api/card/${cardId}/params/${encodeURIComponent(parameterId)}/remapping`,
getRemappedDashboardParameterValueUrl: (
dashboardId: DashboardId | undefined,
parameterId: ParameterId,
) =>
`/api/dashboard/${dashboardId}/params/${encodeURIComponent(parameterId)}/remapping`,
});
export const PLUGIN_API = getDefaultPluginApi();
import { clearOnBeforeRequestHandlers } from "metabase/api/client/middleware";
/**
* @internal Do not call directly. Use the main reinitialize function from metabase/plugins instead.
*/
export function reinitialize() {
Object.assign(PLUGIN_API, getDefaultPluginApi());
// Request handlers are registered by feature init flows (embeds, SDK auth,
// …), so resetting plugins must also drop them from the shared registry.
clearOnBeforeRequestHandlers();
}

View File

@@ -1,16 +1,5 @@
import type { OnBeforeRequestHandlerConfig } from "metabase/plugins/oss/api";
const getDefaultPluginEmbeddingSdk = () => ({
isEnabled: () => false,
onBeforeRequestHandlers: {
getOrRefreshSessionHandler: async () => {},
getOrRefreshGuestSessionHandler: async (
_data: OnBeforeRequestHandlerConfig,
): Promise<OnBeforeRequestHandlerConfig | void> => {},
overrideRequestsForGuestEmbeds: async (
_data: OnBeforeRequestHandlerConfig,
): Promise<OnBeforeRequestHandlerConfig | void> => {},
},
});
export const PLUGIN_EMBEDDING_SDK = getDefaultPluginEmbeddingSdk();

View File

@@ -20,17 +20,18 @@ export const usePublicEndpoints = ({
// Register synchronously during render rather than from the `useMount` below:
// the dashboard fetcher lives in a child whose effect fires before this
// parent's effects, so a `useMount` registration would miss the first embed
// request. The call is idempotent.
if (isEmbed) {
// request. Order matters — the embed override must be registered before the
// preview rewrite so it runs first. Both registrations are idempotent.
if (isPublic) {
overrideRequestsForPublicOrStaticEmbeds("public");
} else if (isEmbed) {
overrideRequestsForPublicOrStaticEmbeds("static");
setupEmbedPreviewRewrite();
}
useMount(() => {
if (isPublic) {
overrideRequestsForPublicOrStaticEmbeds("public");
} else if (token) {
if (!isPublic && token) {
PLUGIN_CONTENT_TRANSLATION.setEndpointsForStaticEmbedding(token);
overrideRequestsForPublicOrStaticEmbeds("static");
}
});
};