diff --git a/packages/toolpad-app/jest-environment-jsdom.ts b/packages/toolpad-app/jest-environment-jsdom.ts index 28319eb61da..cb8b54b14e8 100644 --- a/packages/toolpad-app/jest-environment-jsdom.ts +++ b/packages/toolpad-app/jest-environment-jsdom.ts @@ -1,11 +1,19 @@ import { TextDecoder, TextEncoder } from 'util'; import fetch, { Headers, Request, Response } from 'node-fetch'; import JsdomEnvironment from 'jest-environment-jsdom'; +import { RuntimeConfig } from './src/config'; +import { RUNTIME_CONFIG_WINDOW_PROPERTY } from './src/constants'; + +function setRuntimeConfig(win: Window, config: RuntimeConfig) { + win[RUNTIME_CONFIG_WINDOW_PROPERTY] = config; +} export default class CustomJsdomEnvironment extends JsdomEnvironment { async setup() { await super.setup(); + setRuntimeConfig(this.global, { externalUrl: 'http://localhost:3000', isDemo: false }); + if (!this.global.TextDecoder) { // @ts-expect-error The polyfill is not 100% spec-compliant this.global.TextDecoder = TextDecoder; diff --git a/packages/toolpad-app/src/appDom/index.ts b/packages/toolpad-app/src/appDom/index.ts index 2db88465d79..f0795df592c 100644 --- a/packages/toolpad-app/src/appDom/index.ts +++ b/packages/toolpad-app/src/appDom/index.ts @@ -1048,7 +1048,13 @@ function createRenderTreeNode(node: AppDomNode): RenderTreeNode | null { } if (isQuery(node) || isMutation(node)) { - node = setNamespacedProp(node, 'attributes', 'query', null); + const isBrowserSideRestQuery: boolean = + node.attributes.dataSource?.value === 'rest' && + !!(node.attributes.query.value as any).browser; + + if (node.attributes.query.value && !isBrowserSideRestQuery) { + node = setNamespacedProp(node, 'attributes', 'query', null); + } } return node as RenderTreeNode; diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index 53e790b1dc7..860841d7579 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -460,7 +460,7 @@ function QueryNode({ node }: QueryNodeProps) { const configBindings = _.pick(node.attributes, USE_DATA_QUERY_CONFIG_KEYS); const options = resolveBindables(bindings, `${node.id}.config`, configBindings); - const queryResult = useDataQuery(node.id, params, options); + const queryResult = useDataQuery(node, params, options); React.useEffect(() => { const { isLoading, error, data, rows, ...result } = queryResult; diff --git a/packages/toolpad-app/src/runtime/evalJsBindings.ts b/packages/toolpad-app/src/runtime/evalJsBindings.ts index 484a67de24e..bcb5c5508b4 100644 --- a/packages/toolpad-app/src/runtime/evalJsBindings.ts +++ b/packages/toolpad-app/src/runtime/evalJsBindings.ts @@ -1,23 +1,8 @@ import { set } from 'lodash-es'; +import evalExpression from '../utils/evalExpression'; import { mapValues } from '../utils/collections'; import { errorFrom } from '../utils/errors'; -let iframe: HTMLIFrameElement; -function evaluateCode(code: string, globalScope: Record) { - // TODO: investigate https://www.npmjs.com/package/ses - if (!iframe) { - iframe = document.createElement('iframe'); - iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts'); - iframe.style.display = 'none'; - document.documentElement.appendChild(iframe); - } - - // eslint-disable-next-line no-underscore-dangle - (iframe.contentWindow as any).__SCOPE = globalScope; - (iframe.contentWindow as any).console = window.console; - return (iframe.contentWindow as any).eval(`with (window.__SCOPE) { ${code} }`); -} - const TOOLPAD_LOADING_MARKER = '__TOOLPAD_LOADING_MARKER__'; export function evaluateExpression( @@ -25,7 +10,7 @@ export function evaluateExpression( globalScope: Record, ): BindingEvaluationResult { try { - const value = evaluateCode(code, globalScope); + const value = evalExpression(code, globalScope); return { value }; } catch (rawError) { const error = errorFrom(rawError); diff --git a/packages/toolpad-app/src/runtime/useDataQuery.ts b/packages/toolpad-app/src/runtime/useDataQuery.ts index 4331e0f08aa..aa7e074a1b3 100644 --- a/packages/toolpad-app/src/runtime/useDataQuery.ts +++ b/packages/toolpad-app/src/runtime/useDataQuery.ts @@ -1,10 +1,11 @@ import { GridRowsProp } from '@mui/x-data-grid-pro'; import * as React from 'react'; import { useQuery, UseQueryOptions } from '@tanstack/react-query'; -import { NodeId } from '@mui/toolpad-core'; import { useAppContext } from './AppContext'; import { VersionOrPreview } from '../types'; import { CanvasHooksContext } from './CanvasHooksContext'; +import dataSources from '../toolpadDataSources/runtime'; +import * as appDom from '../appDom'; interface ExecDataSourceQueryParams { signal?: AbortSignal; @@ -59,7 +60,7 @@ const EMPTY_ARRAY: any[] = []; const EMPTY_OBJECT: any = {}; export function useDataQuery( - queryId: NodeId, + node: appDom.QueryNode, params: any, { enabled = true, @@ -68,6 +69,11 @@ export function useDataQuery( ): UseFetch { const { appId, version } = useAppContext(); const { savedNodes } = React.useContext(CanvasHooksContext); + const queryId = node.id; + const query = node.attributes.query?.value; + const dataSourceId = node.attributes.dataSource?.value; + + const dataSource = dataSourceId ? dataSources[dataSourceId] : null; // These are only used by the editor to invalidate caches whenever the query changes during editing const nodeHash: number | undefined = savedNodes ? savedNodes[queryId] : undefined; @@ -81,15 +87,20 @@ export function useDataQuery( refetch, } = useQuery( [appId, version, nodeHash, queryId, params], - ({ signal }) => - queryId && - execDataSourceQuery({ - signal, - appId, - version, - queryId, - params, - }), + ({ signal }) => { + if (!queryId) { + return null; + } + + const fetchFromServer = () => + execDataSourceQuery({ signal, appId, version, queryId, params }); + + if (query && dataSource?.exec) { + return dataSource?.exec(query, params, fetchFromServer); + } + + return fetchFromServer(); + }, { ...options, enabled: isNodeAvailableOnServer && !!queryId && enabled, diff --git a/packages/toolpad-app/src/toolpadDataSources/demo.tsx b/packages/toolpad-app/src/toolpadDataSources/demo.tsx new file mode 100644 index 00000000000..cbe442aab49 --- /dev/null +++ b/packages/toolpad-app/src/toolpadDataSources/demo.tsx @@ -0,0 +1,3 @@ +import config from '../config'; + +export const MOVIES_API_DEMO_URL = new URL('/static/movies.json', config.externalUrl).href; diff --git a/packages/toolpad-app/src/toolpadDataSources/function/client.tsx b/packages/toolpad-app/src/toolpadDataSources/function/client.tsx index 0bf82776283..df97c5f35b5 100644 --- a/packages/toolpad-app/src/toolpadDataSources/function/client.tsx +++ b/packages/toolpad-app/src/toolpadDataSources/function/client.tsx @@ -25,7 +25,8 @@ import QueryInputPanel from '../QueryInputPanel'; import { useEvaluateLiveBindingEntries } from '../../toolpad/AppEditor/useEvaluateLiveBinding'; import useShortcut from '../../utils/useShortcut'; import { tryFormat } from '../../utils/prettier'; -import config from '../../config'; +import useFetchPrivate from '../useFetchPrivate'; +import { MOVIES_API_DEMO_URL } from '../demo'; const EVENT_INTERFACE_IDENTIFIER = 'ToolpadFunctionEvent'; @@ -84,7 +85,7 @@ function ConnectionParamsInput({ const DEFAULT_MODULE = `export default async function ({ parameters }: ToolpadFunctionEvent) { console.info("Executing function with parameters:", parameters); - const url = new URL("${new URL('/static/movies.json', config.externalUrl).href}"); + const url = new URL("${MOVIES_API_DEMO_URL}"); url.searchParams.set("timestamp", String(Date.now())); const response = await fetch(String(url)); @@ -113,17 +114,19 @@ function QueryEditor({ [paramsEditorLiveValue], ); + const fetchPrivate = useFetchPrivate(); + const fetchServerPreview = React.useCallback( + (query: FunctionQuery, params: Record) => + fetchPrivate({ kind: 'debugExec', query, params }), + [fetchPrivate], + ); + const [previewLogs, setPreviewLogs] = React.useState([]); const [previewHar, setPreviewHar] = React.useState(() => createHarLog()); - const { preview, runPreview: handleRunPreview } = useQueryPreview< - FunctionPrivateQuery, - FunctionResult - >( - { - kind: 'debugExec', - query: input.query, - params: previewParams, - }, + const { preview, runPreview: handleRunPreview } = useQueryPreview( + fetchServerPreview, + input.query, + previewParams, { onPreview(result) { setPreviewLogs((existing) => [...existing, ...result.logs]); diff --git a/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx b/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx index 8d42ed34fc2..e23012bc9b2 100644 --- a/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx +++ b/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx @@ -31,6 +31,7 @@ import ErrorAlert from '../../toolpad/AppEditor/PageEditor/ErrorAlert'; import QueryInputPanel from '../QueryInputPanel'; import SplitPane from '../../components/SplitPane'; import useQueryPreview from '../useQueryPreview'; +import useFetchPrivate from '../useFetchPrivate'; const EMPTY_ROWS: any[] = []; @@ -126,13 +127,17 @@ function QueryEditor({ [], ); - const { preview, runPreview: handleRunPreview } = useQueryPreview< - GoogleSheetsPrivateQuery, - GoogleSheetsResult - >({ - type: 'DEBUG_EXEC', - query: input.query, - }); + const fetchPrivate = useFetchPrivate(); + const fetchServerPreview = React.useCallback( + (query: GoogleSheetsApiQuery) => fetchPrivate({ type: 'DEBUG_EXEC', query }), + [fetchPrivate], + ); + + const { preview, runPreview: handleRunPreview } = useQueryPreview( + fetchServerPreview, + input.query, + {}, + ); const rawRows: any[] = preview?.data || EMPTY_ROWS; const columns: GridColDef[] = React.useMemo(() => parseColumns(inferColumns(rawRows)), [rawRows]); diff --git a/packages/toolpad-app/src/toolpadDataSources/movies/client.tsx b/packages/toolpad-app/src/toolpadDataSources/movies/client.tsx index 7ccd8210730..476e6839bc2 100644 --- a/packages/toolpad-app/src/toolpadDataSources/movies/client.tsx +++ b/packages/toolpad-app/src/toolpadDataSources/movies/client.tsx @@ -12,6 +12,7 @@ import { Maybe } from '../../utils/types'; import useQueryPreview from '../useQueryPreview'; import { MoviesQuery, MoviesConnectionParams } from './types'; import { FetchResult } from '../rest/types'; +import useFetchPrivate from '../useFetchPrivate'; function withDefaults(value: Maybe): MoviesConnectionParams { return { @@ -52,9 +53,17 @@ export function QueryEditor({ [setInput], ); - const { preview, runPreview: handleRunPreview } = useQueryPreview({ - genre: input.query.genre, - }); + const fetchPrivate = useFetchPrivate(); + const fetchServerPreview = React.useCallback( + (query: MoviesQuery) => fetchPrivate(query), + [fetchPrivate], + ); + + const { preview, runPreview: handleRunPreview } = useQueryPreview( + fetchServerPreview, + { genre: input.query.genre }, + {}, + ); return ( diff --git a/packages/toolpad-app/src/toolpadDataSources/postgres/client.tsx b/packages/toolpad-app/src/toolpadDataSources/postgres/client.tsx index 5f3d0300bc5..30a39c55dae 100644 --- a/packages/toolpad-app/src/toolpadDataSources/postgres/client.tsx +++ b/packages/toolpad-app/src/toolpadDataSources/postgres/client.tsx @@ -170,14 +170,18 @@ function QueryEditor({ [paramsEditorLiveValue], ); - const { preview, runPreview: handleRunPreview } = useQueryPreview< - PostgresPrivateQuery, - PostgresResult - >({ - kind: 'debugExec', - query: input.query, - params: previewParams, - }); + const fetchPrivate = useFetchPrivate(); + const fetchServerPreview = React.useCallback( + (query: PostgresQuery, params: Record) => + fetchPrivate({ kind: 'debugExec', query, params }), + [fetchPrivate], + ); + + const { preview, runPreview: handleRunPreview } = useQueryPreview( + fetchServerPreview, + input.query, + previewParams, + ); const rawRows: any[] = preview?.data || EMPTY_ROWS; const columns: GridColDef[] = React.useMemo(() => parseColumns(inferColumns(rawRows)), [rawRows]); diff --git a/packages/toolpad-app/src/toolpadDataSources/rest/client.tsx b/packages/toolpad-app/src/toolpadDataSources/rest/client.tsx index 3ae77f21434..2d8dc54729a 100644 --- a/packages/toolpad-app/src/toolpadDataSources/rest/client.tsx +++ b/packages/toolpad-app/src/toolpadDataSources/rest/client.tsx @@ -12,10 +12,18 @@ import { Typography, Alert, styled, + Checkbox, + FormControlLabel, + FormGroup, } from '@mui/material'; import { Controller, useForm } from 'react-hook-form'; import { TabContext, TabList } from '@mui/lab'; -import { ClientDataSource, ConnectionEditorProps, QueryEditorProps } from '../../types'; +import { + ClientDataSource, + ConnectionEditorProps, + ExecFetchFn, + QueryEditorProps, +} from '../../types'; import { FetchPrivateQuery, FetchQuery, @@ -24,7 +32,7 @@ import { Body, ResponseType, } from './types'; -import { getAuthenticationHeaders, parseBaseUrl } from './shared'; +import { getAuthenticationHeaders, getDefaultUrl, parseBaseUrl } from './shared'; import BindableEditor, { RenderControlParams, } from '../../toolpad/AppEditor/PageEditor/BindableEditor'; @@ -50,6 +58,8 @@ import { createHarLog, mergeHar } from '../../utils/har'; import config from '../../config'; import QueryInputPanel from '../QueryInputPanel'; import DEMO_BASE_URLS from './demoBaseUrls'; +import useFetchPrivate from '../useFetchPrivate'; +import { clientExec } from './runtime'; const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD']; @@ -264,6 +274,7 @@ function QueryEditor({ onChange: setInput, }: QueryEditorProps) { const baseUrl = connectionParams?.baseUrl; + const urlValue: BindableAttrValue = input.query.url || getDefaultUrl(connectionParams); const handleParamsChange = React.useCallback( (newParams: [string, BindableAttrValue][]) => { @@ -292,6 +303,16 @@ function QueryEditor({ [setInput], ); + const handleRunInBrowserChange = React.useCallback( + (event: React.ChangeEvent) => { + setInput((existing) => ({ + ...existing, + query: { ...existing.query, browser: event.target.checked }, + })); + }, + [setInput], + ); + const handleTransformEnabledChange = React.useCallback( (transformEnabled: boolean) => { setInput((existing) => ({ @@ -370,7 +391,7 @@ function QueryEditor({ const liveUrl: LiveBinding = useEvaluateLiveBinding({ server: true, - input: input.query.url, + input: urlValue, globalScope: queryScope, }); @@ -388,16 +409,26 @@ function QueryEditor({ const [activeTab, setActiveTab] = React.useState('urlQuery'); + const fetchPrivate = useFetchPrivate(); + const fetchServerPreview = React.useCallback( + (query: FetchQuery, params: Record) => + fetchPrivate({ kind: 'debugExec', query, params }), + [fetchPrivate], + ); + + const fetchPreview: ExecFetchFn = (query, params) => + clientExec(query, params, fetchServerPreview); + const [previewHar, setPreviewHar] = React.useState(() => createHarLog()); - const { preview, runPreview: handleRunPreview } = useQueryPreview( - { - kind: 'debugExec', - query: input.query, - params: previewParams, - }, + const { preview, runPreview: handleRunPreview } = useQueryPreview( + fetchPreview, + input.query, + previewParams, { onPreview(result) { - setPreviewHar((existing) => mergeHar(createHarLog(), existing, result.har)); + setPreviewHar((existing) => + result.har ? mergeHar(createHarLog(), existing, result.har) : existing, + ); }, }, ); @@ -437,10 +468,18 @@ function QueryEditor({ label="url" propType={{ type: 'string' }} renderControl={(props) => } - value={input.query.url} + value={urlValue} onChange={handleUrlChange} /> + + + } + label="Run in the browser" + /> + @@ -544,10 +583,13 @@ function QueryEditor({ } function getInitialQueryValue(): FetchQuery { - return { url: { type: 'const', value: '' }, method: 'GET', headers: [] }; + return { + method: 'GET', + headers: [], + }; } -const dataSource: ClientDataSource<{}, FetchQuery> = { +const dataSource: ClientDataSource = { displayName: 'Fetch', ConnectionParamsInput, QueryEditor, diff --git a/packages/toolpad-app/src/toolpadDataSources/rest/runtime.tsx b/packages/toolpad-app/src/toolpadDataSources/rest/runtime.tsx new file mode 100644 index 00000000000..ff05ff3971b --- /dev/null +++ b/packages/toolpad-app/src/toolpadDataSources/rest/runtime.tsx @@ -0,0 +1,74 @@ +import type { Entry } from 'har-format'; +import { ExecFetchFn, RuntimeDataSource } from '../../types'; +import { FetchQuery, FetchResult } from './types'; +import { execfetch } from './shared'; +import evalExpression from '../../utils/evalExpression'; +import { createHarLog } from '../../utils/har'; + +export async function clientExec( + fetchQuery: FetchQuery, + params: Record, + serverFetch: ExecFetchFn, +): Promise { + if (fetchQuery.browser) { + const har = createHarLog(); + + const instrumentedFetch = async (...args: Parameters) => { + const startedDateTime = new Date().toISOString(); + const req = new Request(...args); + const url = new URL(req.url); + const res = await window.fetch(req); + const entry: Entry = { + startedDateTime, + request: { + bodySize: 0, + cookies: [], + headers: Array.from(req.headers, ([name, value]) => ({ name, value })), + headersSize: 0, + httpVersion: '', + method: req.method, + queryString: Array.from(url.searchParams, ([name, value]) => ({ name, value })), + url: url.href, + }, + response: { + bodySize: 0, + content: { + mimeType: res.headers.get('content-type') || '', + size: Number(res.headers.get('content-length')) || 0, + }, + cookies: [], + headers: Array.from(res.headers, ([name, value]) => ({ name, value })), + headersSize: 0, + httpVersion: '', + redirectURL: '', + status: res.status, + statusText: res.statusText, + }, + cache: {}, + time: Date.now(), + timings: { + wait: 0, + receive: 0, + }, + }; + har.log.entries.push(entry); + return res; + }; + + const result = await execfetch(fetchQuery, params, { + connection: null, + fetchImpl: instrumentedFetch, + evalExpression, + }); + + return { ...result, har }; + } + + return serverFetch(fetchQuery, params); +} + +const dataSource: RuntimeDataSource = { + exec: clientExec, +}; + +export default dataSource; diff --git a/packages/toolpad-app/src/toolpadDataSources/rest/server.ts b/packages/toolpad-app/src/toolpadDataSources/rest/server.ts index da53f4ef940..28b39412682 100644 --- a/packages/toolpad-app/src/toolpadDataSources/rest/server.ts +++ b/packages/toolpad-app/src/toolpadDataSources/rest/server.ts @@ -1,220 +1,27 @@ -import { - BindableAttrEntries, - BindableAttrValue, - BindableAttrValues, - ExecFetchResult, -} from '@mui/toolpad-core'; -import fetch, { Headers, RequestInit, Response } from 'node-fetch'; +import { ExecFetchResult } from '@mui/toolpad-core'; +import fetch from 'node-fetch'; import { withHarInstrumentation, createHarLog } from '../../server/har'; import { ServerDataSource } from '../../types'; -import { - Body, - FetchPrivateQuery, - FetchQuery, - FetchResult, - RawBody, - RestConnectionParams, - UrlEncodedBody, -} from './types'; -import evalExpression, { Serializable } from '../../server/evalExpression'; -import { removePrefix } from '../../utils/strings'; +import { FetchPrivateQuery, FetchQuery, RestConnectionParams } from './types'; +import serverEvalExpression from '../../server/evalExpression'; import { Maybe } from '../../utils/types'; -import { getAuthenticationHeaders, HTTP_NO_BODY, parseBaseUrl } from './shared'; -import applyTransform from '../../server/applyTransform'; -import { errorFrom } from '../../utils/errors'; -import config from '../../config'; -import DEMO_BASE_URLS from './demoBaseUrls'; - -async function resolveBindable( - bindable: BindableAttrValue, - scope: Record, -): Promise { - if (bindable.type === 'const') { - return bindable.value; - } - if (bindable.type === 'jsExpression') { - return evalExpression(bindable.value, scope); - } - throw new Error( - `Can't resolve bindable of type "${(bindable as BindableAttrValue).type}"`, - ); -} - -async function resolveBindableEntries( - entries: BindableAttrEntries, - scope: Record, -): Promise<[string, any][]> { - return Promise.all( - entries.map(async ([key, value]) => [key, await resolveBindable(value, scope)]), - ); -} - -async function resolveBindables

( - obj: BindableAttrValues

, - scope: Record, -): Promise

{ - return Object.fromEntries( - await resolveBindableEntries(Object.entries(obj) as BindableAttrEntries, scope), - ) as P; -} - -function parseQueryUrl(queryUrl: string, baseUrl: Maybe): URL { - if (baseUrl) { - const parsedBase = parseBaseUrl(baseUrl); - return new URL(parsedBase.href + removePrefix(queryUrl, '/')); - } - - return new URL(queryUrl); -} - -interface ResolvedRawBody { - kind: 'raw'; - contentType: string; - content: string; -} - -async function resolveRawBody( - body: RawBody, - scope: Record, -): Promise { - const { content, contentType } = await resolveBindables( - { - contentType: body.contentType, - content: body.content, - }, - scope, - ); - return { - kind: 'raw', - contentType, - content: String(content), - }; -} - -interface ResolveUrlEncodedBodyBody { - kind: 'urlEncoded'; - content: [string, string][]; -} - -async function resolveUrlEncodedBody( - body: UrlEncodedBody, - scope: Record, -): Promise { - return { - kind: 'urlEncoded', - content: await resolveBindableEntries(body.content, scope), - }; -} - -async function resolveBody(body: Body, scope: Record) { - switch (body.kind) { - case 'raw': - return resolveRawBody(body, scope); - case 'urlEncoded': - return resolveUrlEncodedBody(body, scope); - default: - throw new Error(`Missing case for "${(body as Body).kind}"`); - } -} - -async function readData(res: Response, fetchQuery: FetchQuery): Promise { - if (!fetchQuery.response || fetchQuery.response?.kind === 'json') { - return res.json(); - } - if (fetchQuery.response?.kind === 'raw') { - return res.text(); - } - throw new Error(`Unsupported response type "${fetchQuery.response.kind}"`); -} +import { execfetch } from './shared'; async function execBase( connection: Maybe, fetchQuery: FetchQuery, params: Record, -): Promise { - const queryScope = { - // TODO: remove deprecated query after v1 - query: params, - parameters: params, - }; - - const [resolvedUrl, resolvedSearchParams, resolvedHeaders] = await Promise.all([ - resolveBindable(fetchQuery.url, queryScope), - resolveBindableEntries(fetchQuery.searchParams || [], queryScope), - resolveBindableEntries(fetchQuery.headers || [], queryScope), - ]); - - if (config.isDemo) { - const demoUrls = DEMO_BASE_URLS.map((baseUrl) => baseUrl.url); - - const hasNonDemoConnectionParams = - !demoUrls.includes(connection?.baseUrl || '') || - (!!connection?.headers && connection.headers.length > 0) || - !!connection?.authentication; - const hasNonDemoQueryParams = - fetchQuery.method !== 'GET' || (!!fetchQuery.headers && fetchQuery.headers.length > 0); - - if (hasNonDemoConnectionParams || hasNonDemoQueryParams) { - throw new Error(`Cannot use these features in demo version.`); - } - } - - const queryUrl = parseQueryUrl(resolvedUrl, connection?.baseUrl); - resolvedSearchParams.forEach(([key, value]) => queryUrl.searchParams.append(key, value)); - - const headers = new Headers([ - ...(connection ? getAuthenticationHeaders(connection.authentication) : []), - ...(connection?.headers || []), - ]); - resolvedHeaders.forEach(([key, value]) => headers.append(key, value)); - - const method = fetchQuery.method || 'GET'; - - const requestInit: RequestInit = { method, headers }; - - if (!HTTP_NO_BODY.has(method) && fetchQuery.body) { - const resolvedBody = await resolveBody(fetchQuery.body, queryScope); - - switch (resolvedBody.kind) { - case 'raw': { - headers.set('content-type', resolvedBody.contentType); - requestInit.body = resolvedBody.content; - break; - } - case 'urlEncoded': { - headers.set('content-type', 'application/x-www-form-urlencoded'); - requestInit.body = new URLSearchParams(resolvedBody.content).toString(); - break; - } - default: - throw new Error(`Missing case for "${(resolvedBody as any).kind}"`); - } - } - - let error: Error | undefined; - let untransformedData; - let data; +) { const har = createHarLog(); + const instrumentedFetch = withHarInstrumentation(fetch, { har }); - try { - const instrumentedFetch = withHarInstrumentation(fetch, { har }); - const res = await instrumentedFetch(queryUrl.href, requestInit); - - if (!res.ok) { - throw new Error(`HTTP ${res.status} (${res.statusText}) while fetching "${res.url}"`); - } - - untransformedData = await readData(res, fetchQuery); - data = untransformedData; - - if (fetchQuery.transformEnabled && fetchQuery.transform) { - data = await applyTransform(fetchQuery.transform, untransformedData); - } - } catch (rawError) { - error = errorFrom(rawError); - } + const result = await execfetch(fetchQuery, params, { + connection, + evalExpression: serverEvalExpression, + fetchImpl: instrumentedFetch as any, + }); - return { data, untransformedData, error, har }; + return { ...result, har }; } async function execPrivate(connection: Maybe, query: FetchPrivateQuery) { diff --git a/packages/toolpad-app/src/toolpadDataSources/rest/shared.ts b/packages/toolpad-app/src/toolpadDataSources/rest/shared.ts index 445ebdf1941..44488e30b2a 100644 --- a/packages/toolpad-app/src/toolpadDataSources/rest/shared.ts +++ b/packages/toolpad-app/src/toolpadDataSources/rest/shared.ts @@ -1,6 +1,22 @@ -import { ensureSuffix } from '../../utils/strings'; +import { BindableAttrEntries, BindableAttrValue, BindableAttrValues } from '@mui/toolpad-core'; +import { ensureSuffix, removePrefix } from '../../utils/strings'; import { Maybe } from '../../utils/types'; -import { Authentication } from './types'; +import { + Authentication, + Body, + FetchQuery, + FetchResult, + RawBody, + RestConnectionParams, + UrlEncodedBody, +} from './types'; +// TODO: move to ../../types +import type { Serializable } from '../../server/evalExpression'; +import applyTransform from '../../server/applyTransform'; +import { errorFrom } from '../../utils/errors'; +import config from '../../config'; +import DEMO_BASE_URLS from './demoBaseUrls'; +import { MOVIES_API_DEMO_URL } from '../demo'; export const HTTP_NO_BODY = new Set(['GET', 'HEAD']); @@ -33,3 +49,223 @@ export function parseBaseUrl(baseUrl: string): URL { parsedBase.hash = ''; return parsedBase; } + +interface EvalExpression { + (expression: string, scope: Record): Promise; +} + +async function resolveBindable( + bindable: BindableAttrValue, + evalExpression: EvalExpression, + scope: Record, +): Promise { + if (bindable.type === 'const') { + return bindable.value; + } + if (bindable.type === 'jsExpression') { + return evalExpression(bindable.value, scope); + } + throw new Error( + `Can't resolve bindable of type "${(bindable as BindableAttrValue).type}"`, + ); +} + +async function resolveBindableEntries( + entries: BindableAttrEntries, + evalExpression: EvalExpression, + scope: Record, +): Promise<[string, any][]> { + return Promise.all( + entries.map(async ([key, value]) => [key, await resolveBindable(value, evalExpression, scope)]), + ); +} + +async function resolveBindables

( + obj: BindableAttrValues

, + evalExpression: EvalExpression, + scope: Record, +): Promise

{ + return Object.fromEntries( + await resolveBindableEntries(Object.entries(obj) as BindableAttrEntries, evalExpression, scope), + ) as P; +} + +function parseQueryUrl(queryUrl: string, baseUrl: Maybe): URL { + if (baseUrl) { + const parsedBase = parseBaseUrl(baseUrl); + return new URL(parsedBase.href + removePrefix(queryUrl, '/')); + } + + return new URL(queryUrl); +} + +interface ResolvedRawBody { + kind: 'raw'; + contentType: string; + content: string; +} + +async function resolveRawBody( + body: RawBody, + evalExpression: EvalExpression, + scope: Record, +): Promise { + const { content, contentType } = await resolveBindables( + { + contentType: body.contentType, + content: body.content, + }, + evalExpression, + scope, + ); + return { + kind: 'raw', + contentType, + content: String(content), + }; +} + +interface ResolveUrlEncodedBodyBody { + kind: 'urlEncoded'; + content: [string, string][]; +} + +async function resolveUrlEncodedBody( + body: UrlEncodedBody, + evalExpression: EvalExpression, + scope: Record, +): Promise { + return { + kind: 'urlEncoded', + content: await resolveBindableEntries(body.content, evalExpression, scope), + }; +} + +async function resolveBody( + body: Body, + evalExpression: EvalExpression, + scope: Record, +) { + switch (body.kind) { + case 'raw': + return resolveRawBody(body, evalExpression, scope); + case 'urlEncoded': + return resolveUrlEncodedBody(body, evalExpression, scope); + default: + throw new Error(`Missing case for "${(body as Body).kind}"`); + } +} + +async function readData(res: Response, fetchQuery: FetchQuery): Promise { + if (!fetchQuery.response || fetchQuery.response?.kind === 'json') { + return res.json(); + } + if (fetchQuery.response?.kind === 'raw') { + return res.text(); + } + throw new Error(`Unsupported response type "${fetchQuery.response.kind}"`); +} + +export function getDefaultUrl(connection?: RestConnectionParams | null): BindableAttrValue { + const baseUrl = connection?.baseUrl; + return { + type: 'const', + value: baseUrl ? '' : MOVIES_API_DEMO_URL, + }; +} + +interface ExecBaseOptions { + connection?: Maybe; + evalExpression: EvalExpression; + fetchImpl: typeof fetch; +} + +export async function execfetch( + fetchQuery: FetchQuery, + params: Record, + { connection, evalExpression, fetchImpl }: ExecBaseOptions, +): Promise { + const queryScope = { + // TODO: remove deprecated query after v1 + query: params, + parameters: params, + }; + + const urlvalue = fetchQuery.url || getDefaultUrl(connection); + + const [resolvedUrl, resolvedSearchParams, resolvedHeaders] = await Promise.all([ + resolveBindable(urlvalue, evalExpression, queryScope), + resolveBindableEntries(fetchQuery.searchParams || [], evalExpression, queryScope), + resolveBindableEntries(fetchQuery.headers || [], evalExpression, queryScope), + ]); + + if (config.isDemo) { + const demoUrls = DEMO_BASE_URLS.map((baseUrl) => baseUrl.url); + + const hasNonDemoConnectionParams = + !demoUrls.includes(connection?.baseUrl || '') || + (!!connection?.headers && connection.headers.length > 0) || + !!connection?.authentication; + const hasNonDemoQueryParams = + fetchQuery.method !== 'GET' || (!!fetchQuery.headers && fetchQuery.headers.length > 0); + + if (hasNonDemoConnectionParams || hasNonDemoQueryParams) { + throw new Error(`Cannot use these features in demo version.`); + } + } + + const queryUrl = parseQueryUrl(resolvedUrl, connection?.baseUrl); + resolvedSearchParams.forEach(([key, value]) => queryUrl.searchParams.append(key, value)); + + const headers = new Headers([ + ...(connection ? getAuthenticationHeaders(connection.authentication) : []), + ...(connection?.headers || []), + ]); + resolvedHeaders.forEach(([key, value]) => headers.append(key, value)); + + const method = fetchQuery.method || 'GET'; + + const requestInit: RequestInit = { method, headers }; + + if (!HTTP_NO_BODY.has(method) && fetchQuery.body) { + const resolvedBody = await resolveBody(fetchQuery.body, evalExpression, queryScope); + + switch (resolvedBody.kind) { + case 'raw': { + headers.set('content-type', resolvedBody.contentType); + requestInit.body = resolvedBody.content; + break; + } + case 'urlEncoded': { + headers.set('content-type', 'application/x-www-form-urlencoded'); + requestInit.body = new URLSearchParams(resolvedBody.content).toString(); + break; + } + default: + throw new Error(`Missing case for "${(resolvedBody as any).kind}"`); + } + } + + let error: Error | undefined; + let untransformedData; + let data; + + try { + const res = await fetchImpl(queryUrl.href, requestInit); + + if (!res.ok) { + throw new Error(`HTTP ${res.status} (${res.statusText}) while fetching "${res.url}"`); + } + + untransformedData = await readData(res, fetchQuery); + data = untransformedData; + + if (fetchQuery.transformEnabled && fetchQuery.transform) { + data = await applyTransform(fetchQuery.transform, untransformedData); + } + } catch (rawError) { + error = errorFrom(rawError); + } + + return { data, untransformedData, error }; +} diff --git a/packages/toolpad-app/src/toolpadDataSources/rest/types.ts b/packages/toolpad-app/src/toolpadDataSources/rest/types.ts index bc806e5d540..a9622da2162 100644 --- a/packages/toolpad-app/src/toolpadDataSources/rest/types.ts +++ b/packages/toolpad-app/src/toolpadDataSources/rest/types.ts @@ -67,10 +67,14 @@ export type XmlResponseType = { export type ResponseType = RawResponseType | JsonResponseType | CsvResponseType | XmlResponseType; export interface FetchQuery { + /** + * Run in the browser. + */ + readonly browser?: boolean; /** * The URL of the rquest. */ - readonly url: BindableAttrValue; + readonly url?: BindableAttrValue; /** * The request method. */ @@ -115,5 +119,5 @@ export type FetchPrivateQuery = { export interface FetchResult extends ExecFetchResult { data: any; untransformedData: any; - har: Har; + har?: Har; } diff --git a/packages/toolpad-app/src/toolpadDataSources/runtime.tsx b/packages/toolpad-app/src/toolpadDataSources/runtime.tsx new file mode 100644 index 00000000000..1145ad86afa --- /dev/null +++ b/packages/toolpad-app/src/toolpadDataSources/runtime.tsx @@ -0,0 +1,10 @@ +import rest from './rest/runtime'; +import { RuntimeDataSource } from '../types'; + +type RuntimeDataSources = { [key: string]: RuntimeDataSource | undefined }; + +const runtimeDataSources: RuntimeDataSources = { + rest, +}; + +export default runtimeDataSources; diff --git a/packages/toolpad-app/src/toolpadDataSources/useQueryPreview.ts b/packages/toolpad-app/src/toolpadDataSources/useQueryPreview.ts index 0a75b5fc49c..113e155fbb7 100644 --- a/packages/toolpad-app/src/toolpadDataSources/useQueryPreview.ts +++ b/packages/toolpad-app/src/toolpadDataSources/useQueryPreview.ts @@ -1,20 +1,19 @@ import { ExecFetchResult } from '@mui/toolpad-core'; import * as React from 'react'; import { errorFrom, serializeError } from '../utils/errors'; -import useFetchPrivate from './useFetchPrivate'; export interface UseQueryPreviewOptions { onPreview?: (result: R) => void; } -export default function useQueryPreview & Partial>( - privateQuery: PQ, +export default function useQueryPreview & Partial>( + dofetch: (query: Q, params: P) => Promise, + query: Q, + params: P, { onPreview = () => {} }: UseQueryPreviewOptions = {}, ) { const [preview, setPreview] = React.useState(null); - const fetchPrivate = useFetchPrivate(); - const cancelRunPreview = React.useRef<(() => void) | null>(null); const runPreview = React.useCallback(() => { let canceled = false; @@ -24,7 +23,7 @@ export default function useQueryPreview & Par canceled = true; }; - fetchPrivate(privateQuery) + dofetch(query, params) .then( (result) => { if (!canceled) { @@ -39,7 +38,7 @@ export default function useQueryPreview & Par .finally(() => { cancelRunPreview.current = null; }); - }, [fetchPrivate, privateQuery, onPreview]); + }, [dofetch, query, params, onPreview]); return { preview, runPreview }; } diff --git a/packages/toolpad-app/src/types.ts b/packages/toolpad-app/src/types.ts index 62ce95b96bc..4af3eea7ec0 100644 --- a/packages/toolpad-app/src/types.ts +++ b/packages/toolpad-app/src/types.ts @@ -97,6 +97,14 @@ export interface ConnectionStatus { error?: string; } +export interface ExecFetchFn { + (fetchQuery: Q, params: Record): Promise; +} + +export interface ExecClientFetchFn { + (fetchQuery: Q, params: Record, serverFetch: ExecFetchFn): Promise; +} + export interface ClientDataSource { displayName: string; ConnectionParamsInput: ConnectionParamsEditor; @@ -106,6 +114,10 @@ export interface ClientDataSource { hasDefault?: boolean; } +export interface RuntimeDataSource { + exec?: ExecClientFetchFn; +} + export interface ServerDataSource

{ // Execute a private query on this connection, intended for editors only execPrivate?: (connection: Maybe

, query: PQ) => Promise; diff --git a/packages/toolpad-app/src/utils/evalExpression.ts b/packages/toolpad-app/src/utils/evalExpression.ts new file mode 100644 index 00000000000..202c7e93c7d --- /dev/null +++ b/packages/toolpad-app/src/utils/evalExpression.ts @@ -0,0 +1,15 @@ +let iframe: HTMLIFrameElement; +export default function evalExpression(code: string, globalScope: Record) { + // TODO: investigate https://www.npmjs.com/package/ses + if (!iframe) { + iframe = document.createElement('iframe'); + iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts'); + iframe.style.display = 'none'; + document.documentElement.appendChild(iframe); + } + + // eslint-disable-next-line no-underscore-dangle + (iframe.contentWindow as any).__SCOPE = globalScope; + (iframe.contentWindow as any).console = window.console; + return (iframe.contentWindow as any).eval(`with (window.__SCOPE) { ${code} }`); +}