diff --git a/.github/workflows/release-embedding-sdk.yml b/.github/workflows/release-embedding-sdk.yml index cccd93f345bacded0604d690fec929e3f33d2288..044c50f207322d3951408f6797d0b68fe9824cf1 100644 --- a/.github/workflows/release-embedding-sdk.yml +++ b/.github/workflows/release-embedding-sdk.yml @@ -89,6 +89,7 @@ jobs: ./bin/embedding-sdk/release_utils.bash update_readme ${{ inputs.sdk_version }} - name: Bump published npm package version + # NOTE: this should happen before "Build SDK bundle" as we inject SDK version into the code during build step run: | ./bin/embedding-sdk/release_utils.bash update_package_json_template ${{ inputs.sdk_version }} diff --git a/.storybook/main.js b/.storybook/main.js index 597f8cfe5ea52087f25931bc55f38bcb62a6a407..58b21300a164e6e3436cf2f87809f34bf0b7bb3e 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,6 +1,8 @@ const webpack = require("webpack"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const appConfig = require("../webpack.config"); +const fs = require("fs"); +const path = require("path"); const isEmbeddingSDK = process.env.IS_EMBEDDING_SDK === "true"; @@ -13,6 +15,15 @@ const embeddingSdkStories = [ "../enterprise/frontend/src/embedding-sdk/**/*.stories.tsx", ]; +const sdkPackageTemplateJson = fs.readFileSync( + path.resolve("./enterprise/frontend/src/embedding-sdk/package.template.json"), + "utf-8", +); +const sdkPackageTemplateJsonContent = JSON.parse(sdkPackageTemplateJson); +const EMBEDDING_SDK_VERSION = JSON.stringify( + sdkPackageTemplateJsonContent.version, +); + module.exports = { core: { builder: "webpack5", @@ -36,6 +47,9 @@ module.exports = { new webpack.ProvidePlugin({ Buffer: ["buffer", "Buffer"], }), + new webpack.EnvironmentPlugin({ + EMBEDDING_SDK_VERSION, + }), ], module: { ...storybookConfig.module, diff --git a/e2e/support/helpers/e2e-embedding-helpers.js b/e2e/support/helpers/e2e-embedding-helpers.js index 36a16603d92b0f2afb51fd2e19229716cb38ff66..a6fe8a95f6a5f63fbd423eeaf94655202a31ede2 100644 --- a/e2e/support/helpers/e2e-embedding-helpers.js +++ b/e2e/support/helpers/e2e-embedding-helpers.js @@ -38,7 +38,7 @@ import { modal, popover } from "e2e/support/helpers/e2e-ui-elements-helpers"; */ export function visitEmbeddedPage( payload, - { setFilters = {}, hideFilters = [], pageStyle = {} } = {}, + { setFilters = {}, hideFilters = [], pageStyle = {}, onBeforeLoad } = {}, ) { const jwtSignLocation = "e2e/support/external/e2e-jwt-sign.js"; @@ -63,6 +63,7 @@ export function visitEmbeddedPage( url: urlRoot, qs: setFilters, onBeforeLoad: window => { + onBeforeLoad?.(window); if (urlHash) { window.location.hash = urlHash; } diff --git a/e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js b/e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js index f034e4ee6208c6ed5e363111153facaa834e8161..fa6df526eee6082607397052732398fc094bb8d3 100644 --- a/e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js @@ -538,6 +538,34 @@ describe("scenarios > embedding > dashboard parameters", () => { ); dismissDownloadStatus(); }); + + it("should send 'X-Metabase-Client' header for api requests", () => { + cy.intercept("GET", "api/embed/dashboard/*").as("getEmbeddedDashboard"); + + cy.get("@dashboardId").then(dashboardId => { + cy.request("PUT", `/api/dashboard/${dashboardId}`, { + embedding_params: {}, + enable_embedding: true, + }); + + const payload = { + resource: { dashboard: dashboardId }, + params: {}, + }; + + visitEmbeddedPage(payload, { + onBeforeLoad: window => { + window.Cypress = undefined; + }, + }); + + cy.wait("@getEmbeddedDashboard").then(({ request }) => { + expect(request?.headers?.["x-metabase-client"]).to.equal( + "embedding-iframe", + ); + }); + }); + }); }); describe("scenarios > embedding > dashboard parameters with defaults", () => { diff --git a/e2e/test/scenarios/embedding/interactive-embedding.cy.spec.js b/e2e/test/scenarios/embedding/interactive-embedding.cy.spec.js index 5804119f1d6cc107938f6e4cfcd8bea293433d5e..3b10f75768014eb26bf4ceb460e17e6cd41a80e5 100644 --- a/e2e/test/scenarios/embedding/interactive-embedding.cy.spec.js +++ b/e2e/test/scenarios/embedding/interactive-embedding.cy.spec.js @@ -117,7 +117,7 @@ describeEE("scenarios > embedding > full app", () => { sideNav().should("not.exist"); }); - it("should disable home link when top nav is enabeld but side nav is disabled", () => { + it("should disable home link when top nav is enabled but side nav is disabled", () => { visitDashboardUrl({ url: `/dashboard/${ORDERS_DASHBOARD_ID}`, qs: { top_nav: true, side_nav: false }, @@ -226,6 +226,19 @@ describeEE("scenarios > embedding > full app", () => { cy.button("Filter").should("not.exist"); }); + it("should send 'X-Metabase-Client' header for api requests", () => { + visitFullAppEmbeddingUrl({ + url: "/question/" + ORDERS_QUESTION_ID, + qs: { action_buttons: false }, + }); + + cy.wait("@getCardQuery").then(({ request }) => { + expect(request?.headers?.["x-metabase-client"]).to.equal( + "embedding-iframe", + ); + }); + }); + describe("question creation", () => { beforeEach(() => { cy.signOut(); @@ -559,6 +572,16 @@ describeEE("scenarios > embedding > full app", () => { ).to.equal(CSRF_TOKEN); }); }); + + it("should send 'X-Metabase-Client' header for api requests", () => { + visitFullAppEmbeddingUrl({ url: `/dashboard/${ORDERS_DASHBOARD_ID}` }); + + cy.wait("@getDashboard").then(({ request }) => { + expect(request?.headers?.["x-metabase-client"]).to.equal( + "embedding-iframe", + ); + }); + }); }); describe("x-ray dashboards", () => { diff --git a/enterprise/frontend/src/embedding-sdk/config.ts b/enterprise/frontend/src/embedding-sdk/config.ts index 68d9c718a5d170210b72a57739d93ef3b3014b3e..a80fe74f6560ecdb7df4694942ba4d606319d1e2 100644 --- a/enterprise/frontend/src/embedding-sdk/config.ts +++ b/enterprise/frontend/src/embedding-sdk/config.ts @@ -1,2 +1,5 @@ export const DEFAULT_FONT = "Lato"; export const EMBEDDING_SDK_ROOT_ELEMENT_ID = "metabase-sdk-root"; + +export const getEmbeddingSdkVersion = () => + process.env.EMBEDDING_SDK_VERSION ?? "unknown"; diff --git a/enterprise/frontend/src/embedding-sdk/hooks/private/use-init-data.ts b/enterprise/frontend/src/embedding-sdk/hooks/private/use-init-data.ts index e6e95661a40fae903b6d99e6ecf9e7d698502bde..9cc8d9f65145c6fc83063dd883ae23d8e5f610c8 100644 --- a/enterprise/frontend/src/embedding-sdk/hooks/private/use-init-data.ts +++ b/enterprise/frontend/src/embedding-sdk/hooks/private/use-init-data.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import _ from "underscore"; +import { getEmbeddingSdkVersion } from "embedding-sdk/config"; import { getAuthConfiguration } from "embedding-sdk/hooks/private/get-auth-configuration"; import { getErrorMessage } from "embedding-sdk/lib/user-warnings/constants"; import { useSdkDispatch, useSdkSelector } from "embedding-sdk/store"; @@ -31,7 +32,19 @@ export const useInitData = ({ config }: InitDataLoaderParameters) => { useEffect(() => { registerVisualizationsOnce(); - }, [dispatch]); + + const EMBEDDING_SDK_VERSION = getEmbeddingSdkVersion(); + api.requestClient = { + name: "embedding-sdk-react", + version: EMBEDDING_SDK_VERSION, + }; + + // eslint-disable-next-line no-console + console.log( + // eslint-disable-next-line no-literal-metabase-strings -- Not a user facing string + `Using Metabase Embedding SDK, version "${EMBEDDING_SDK_VERSION}"`, + ); + }, []); useEffect(() => { dispatch(setFetchRefreshTokenFn(config.fetchRequestToken ?? null)); diff --git a/enterprise/frontend/src/embedding-sdk/hooks/private/use-init-data.unit.spec.tsx b/enterprise/frontend/src/embedding-sdk/hooks/private/use-init-data.unit.spec.tsx index f371a6a2d6612569904b274ecfcf3b8b1988fb6b..a8f10a2cb5eb5607adce83b5d6302eec9b6b2a36 100644 --- a/enterprise/frontend/src/embedding-sdk/hooks/private/use-init-data.unit.spec.tsx +++ b/enterprise/frontend/src/embedding-sdk/hooks/private/use-init-data.unit.spec.tsx @@ -1,4 +1,5 @@ -import { act } from "@testing-library/react"; +import { waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import fetchMock from "fetch-mock"; import { setupEnterprisePlugins } from "__support__/enterprise"; @@ -9,6 +10,7 @@ import { } from "__support__/server-mocks"; import { mockSettings } from "__support__/settings"; import { renderWithProviders, screen } from "__support__/ui"; +import * as sdkConfigModule from "embedding-sdk/config"; import { useInitData } from "embedding-sdk/hooks"; import { sdkReducers, useSdkSelector } from "embedding-sdk/store"; import { refreshTokenAsync } from "embedding-sdk/store/reducer"; @@ -20,6 +22,7 @@ import { createMockSdkState, } from "embedding-sdk/test/mocks/state"; import type { SDKConfig, SDKConfigWithJWT } from "embedding-sdk/types"; +import { GET } from "metabase/lib/api"; import { useDispatch } from "metabase/lib/redux"; import { createMockSettings, @@ -46,6 +49,10 @@ const TestComponent = ({ config }: { config: SDKConfig }) => { const refreshToken = () => dispatch(refreshTokenAsync("http://TEST_URI/sso/metabase")); + const handleClick = () => { + GET("/api/some/url")(); + }; + return ( <div data-testid="test-component" @@ -55,6 +62,7 @@ const TestComponent = ({ config }: { config: SDKConfig }) => { > Test Component <button onClick={refreshToken}>Refresh Token</button> + <button onClick={handleClick}>Send test request</button> </div> ); }; @@ -75,6 +83,8 @@ const setup = ({ iat: 1965805007, }); + fetchMock.get("path:/api/some/url", {}); + setupCurrentUserEndpoint( TEST_USER, isValidUser @@ -131,6 +141,29 @@ describe("useInitData hook", () => { "No JWT URI or API key provided.", ); }); + + it("should set a context for all API requests", async () => { + jest + .spyOn(sdkConfigModule, "getEmbeddingSdkVersion") + .mockImplementationOnce(() => "1.2.3"); + + setup({}); + + await userEvent.click(screen.getByText("Send test request")); + + await waitFor(() => { + expect(fetchMock.called("path:/api/some/url")).toBeTruthy(); + }); + + const lastCallRequest = fetchMock.lastCall("path:/api/some/url")?.request; + + expect(lastCallRequest?.headers.get("X-Metabase-Client")).toEqual( + "embedding-sdk-react", + ); + expect(lastCallRequest?.headers.get("X-Metabase-Client-Version")).toEqual( + "1.2.3", + ); + }); }); describe("JWT authentication", () => { @@ -204,9 +237,7 @@ describe("useInitData hook", () => { rerender(<TestComponent config={config} />); - act(() => { - screen.getByText("Refresh Token").click(); - }); + await userEvent.click(screen.getByText("Refresh Token")); expect(fetchRequestToken).toHaveBeenCalledTimes(1); }); diff --git a/enterprise/frontend/src/embedding-sdk/types/globalTypes.d.ts b/enterprise/frontend/src/embedding-sdk/types/globalTypes.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f6f17272e5f725dec49df93b3ce8a7e4fc3789e --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/types/globalTypes.d.ts @@ -0,0 +1,3 @@ +interface Window { + EMBEDDING_SDK_VERSION?: string; +} diff --git a/frontend/src/metabase/lib/api.js b/frontend/src/metabase/lib/api.js index ca6a4c2d798467b36a2219fefde134275b266d94..136d60db7203b49b47560dac3e0bbb0663639d70 100644 --- a/frontend/src/metabase/lib/api.js +++ b/frontend/src/metabase/lib/api.js @@ -36,6 +36,11 @@ export class Api extends EventEmitter { onBeforeRequest; + /** + * @type {string|{name: string, version: string}} + */ + requestClient; + GET; POST; PUT; @@ -50,6 +55,8 @@ export class Api extends EventEmitter { } _makeMethod(method, creatorOptions = {}) { + const self = this; + return (urlTemplate, methodOptions = {}) => { if (typeof methodOptions === "function") { methodOptions = { transformResponse: methodOptions }; @@ -102,13 +109,27 @@ export class Api extends EventEmitter { } if (this.sessionToken) { - // eslint-disable-next-line no-literal-metabase-strings -- not a UI string + // eslint-disable-next-line no-literal-metabase-strings -- Not a user facing string headers["X-Metabase-Session"] = this.sessionToken; } if (isWithinIframe()) { // eslint-disable-next-line no-literal-metabase-strings -- Not a user facing string headers["X-Metabase-Embedded"] = "true"; + // eslint-disable-next-line no-literal-metabase-strings -- Not a user facing string + headers["X-Metabase-Client"] = "embedding-iframe"; + } + + if (self.requestClient) { + if (typeof self.requestClient === "object") { + // eslint-disable-next-line no-literal-metabase-strings -- Not a user facing string + headers["X-Metabase-Client"] = self.requestClient.name; + // eslint-disable-next-line no-literal-metabase-strings -- Not a user facing string + headers["X-Metabase-Client-Version"] = self.requestClient.version; + } else { + // eslint-disable-next-line no-literal-metabase-strings -- Not a user facing string + headers["X-Metabase-Client"] = self.requestClient; + } } if (ANTI_CSRF_TOKEN) { diff --git a/webpack.embedding-sdk.config.js b/webpack.embedding-sdk.config.js index f13fe1cc51fb1021836d81e73eb8f0aa535b8ec6..dc71228f852bf598f5f03552cc81b2d2126e2660 100644 --- a/webpack.embedding-sdk.config.js +++ b/webpack.embedding-sdk.config.js @@ -10,6 +10,8 @@ const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); const mainConfig = require("./webpack.config"); const { resolve } = require("path"); +const fs = require("fs"); +const path = require("path"); const SDK_SRC_PATH = __dirname + "/enterprise/frontend/src/embedding-sdk"; const BUILD_PATH = __dirname + "/resources/embedding-sdk"; @@ -20,6 +22,15 @@ const ENTERPRISE_SRC_PATH = const WEBPACK_BUNDLE = process.env.WEBPACK_BUNDLE || "development"; const isDevMode = WEBPACK_BUNDLE !== "production"; +const sdkPackageTemplateJson = fs.readFileSync( + path.resolve("./enterprise/frontend/src/embedding-sdk/package.template.json"), + "utf-8", +); +const sdkPackageTemplateJsonContent = JSON.parse(sdkPackageTemplateJson); +const EMBEDDING_SDK_VERSION = JSON.stringify( + sdkPackageTemplateJsonContent.version, +); + // TODO: Reuse babel and css configs from webpack.config.js // Babel: const BABEL_CONFIG = { @@ -136,7 +147,9 @@ module.exports = env => { new webpack.ProvidePlugin({ process: "process/browser.js", }), - + new webpack.EnvironmentPlugin({ + EMBEDDING_SDK_VERSION, + }), new ForkTsCheckerWebpackPlugin({ async: isDevMode, typescript: { diff --git a/webpack.static-viz.config.js b/webpack.static-viz.config.js index 8fac3ca092d844ceb40b839520e692070b887542..b9ba90c2776a0dfa852caf6dd9815eadd1a35432 100644 --- a/webpack.static-viz.config.js +++ b/webpack.static-viz.config.js @@ -1,5 +1,6 @@ const YAML = require("json-to-pretty-yaml"); const TerserPlugin = require("terser-webpack-plugin"); +const webpack = require("webpack"); const { StatsWriterPlugin } = require("webpack-stats-plugin"); const ASSETS_PATH = __dirname + "/resources/frontend_client/app/assets";