diff --git a/apps/builder/app/builder/features/settings-panel/curl.test.ts b/apps/builder/app/builder/features/settings-panel/curl.test.ts index 40775547943c..6328ca3dbfe0 100644 --- a/apps/builder/app/builder/features/settings-panel/curl.test.ts +++ b/apps/builder/app/builder/features/settings-panel/curl.test.ts @@ -4,6 +4,7 @@ import { generateCurl, parseCurl, type CurlRequest } from "./curl"; test("support url", () => { const result = { url: "https://my-url/hello-world", + searchParams: [], method: "get", headers: [], }; @@ -19,6 +20,7 @@ test("support multiline command with backslashes", () => { `) ).toEqual({ url: "https://my-url/hello-world", + searchParams: [], method: "get", headers: [], }); @@ -27,6 +29,7 @@ test("support multiline command with backslashes", () => { test("forgive missing closed quotes", () => { expect(parseCurl(`curl "https://my-url/hello-world`)).toEqual({ url: "https://my-url/hello-world", + searchParams: [], method: "get", headers: [], }); @@ -43,6 +46,7 @@ test("skip when invalid", () => { test("support method with --request and -X flags", () => { const result = { url: "https://my-url/hello-world", + searchParams: [], method: "post", headers: [], }; @@ -62,19 +66,41 @@ test("support --get and -G flags", () => { expect( parseCurl(`curl --get https://my-url --data limit=3 --data first=0`) ).toEqual({ - url: "https://my-url?limit=3&first=0", + url: "https://my-url/", + searchParams: [ + { name: "limit", value: "3" }, + { name: "first", value: "0" }, + ], method: "get", headers: [], }); expect(parseCurl(`curl -G https://my-url -d limit=3 -d first=0`)).toEqual({ - url: "https://my-url?limit=3&first=0", + url: "https://my-url/", + searchParams: [ + { name: "limit", value: "3" }, + { name: "first", value: "0" }, + ], method: "get", headers: [], }); expect( parseCurl(`curl -G https://my-url?filter=1 -d limit=3 -d first=0`) ).toEqual({ - url: "https://my-url?filter=1&limit=3&first=0", + url: "https://my-url/", + searchParams: [ + { name: "filter", value: "1" }, + { name: "limit", value: "3" }, + { name: "first", value: "0" }, + ], + method: "get", + headers: [], + }); + expect(parseCurl(`curl -G https://my-url?filter -d limit`)).toEqual({ + url: "https://my-url/", + searchParams: [ + { name: "filter", value: "" }, + { name: "limit", value: "" }, + ], method: "get", headers: [], }); @@ -85,12 +111,14 @@ test("support headers with --header and -H flags", () => { parseCurl(`curl https://my-url/hello-world --header "name: value"`) ).toEqual({ url: "https://my-url/hello-world", + searchParams: [], method: "get", headers: [{ name: "name", value: "value" }], }); expect(parseCurl(`curl https://my-url/hello-world -H "name: value"`)).toEqual( { url: "https://my-url/hello-world", + searchParams: [], method: "get", headers: [{ name: "name", value: "value" }], } @@ -101,6 +129,7 @@ test("support headers with --header and -H flags", () => { ) ).toEqual({ url: "https://my-url/hello-world", + searchParams: [], method: "get", headers: [ { name: "name", value: "value1" }, @@ -119,7 +148,8 @@ test("default to post method and urlencoded header when data is specified", () = --data-raw param=4 `) ).toEqual({ - url: "https://my-url", + url: "https://my-url/", + searchParams: [], method: "post", headers: [ { name: "content-type", value: "application/x-www-form-urlencoded" }, @@ -132,7 +162,8 @@ test("encode data for get request", () => { expect( parseCurl(`curl -G https://my-url --data-urlencode param=привет`) ).toEqual({ - url: "https://my-url?param=%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82", + url: "https://my-url/", + searchParams: [{ name: "param", value: "привет" }], method: "get", headers: [], }); @@ -142,7 +173,8 @@ test("encode data for post request", () => { expect( parseCurl(`curl https://my-url --data-urlencode param=привет`) ).toEqual({ - url: "https://my-url", + url: "https://my-url/", + searchParams: [], method: "post", headers: [ { name: "content-type", value: "application/x-www-form-urlencoded" }, @@ -160,6 +192,7 @@ test("support text body", () => { `) ).toEqual({ url: "https://my-url/hello-world", + searchParams: [], method: "post", headers: [{ name: "content-type", value: "plain/text" }], body: `{"param":"value"}`, @@ -170,6 +203,7 @@ test("support text body", () => { ) ).toEqual({ url: "https://my-url/hello-world", + searchParams: [], method: "post", headers: [{ name: "content-type", value: "plain/text" }], body: `{"param":"value"}`, @@ -186,6 +220,7 @@ test("support text body with explicit method", () => { `) ).toEqual({ url: "https://my-url/hello-world", + searchParams: [], method: "put", headers: [{ name: "content-type", value: "plain/text" }], body: `{"param":"value"}`, @@ -199,6 +234,7 @@ test("support json body", () => { ) ).toEqual({ url: "https://my-url/hello-world", + searchParams: [], method: "post", headers: [{ name: "content-type", value: "application/json" }], body: { param: "value" }, @@ -213,12 +249,13 @@ test("generate curl with json body", () => { expect( generateCurl({ url: "https://my-url.com", + searchParams: [], method: "post", headers: [{ name: "content-type", value: "application/json" }], body: { param: "value" }, }) ).toMatchInlineSnapshot(` -"curl "https://my-url.com" \\ +"curl "https://my-url.com/" \\ --request post \\ --header "content-type: application/json" \\ --data "{\\"param\\":\\"value\\"}"" @@ -229,12 +266,13 @@ test("generate curl with text body", () => { expect( generateCurl({ url: "https://my-url.com", + searchParams: [], method: "post", headers: [], body: "my data", }) ).toMatchInlineSnapshot(` -"curl "https://my-url.com" \\ +"curl "https://my-url.com/" \\ --request post \\ --data "my data"" `); @@ -244,18 +282,38 @@ test("generate curl without body", () => { expect( generateCurl({ url: "https://my-url.com", + searchParams: [], method: "post", headers: [], }) ).toMatchInlineSnapshot(` -"curl "https://my-url.com" \\ +"curl "https://my-url.com/" \\ --request post" `); }); +test("generate curl with search params", () => { + expect( + generateCurl({ + url: "https://my-url.com", + searchParams: [ + { name: "search", value: "term1" }, + { name: "search", value: "term2" }, + { name: "filter", value: "привет" }, + ], + method: "get", + headers: [], + }) + ).toMatchInlineSnapshot(` +"curl "https://my-url.com/?search=term1&search=term2&filter=%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82" \\ + --request get" +`); +}); + test("multiline graphql is idempotent", () => { const request: CurlRequest = { url: "https://eu-central-1-shared-euc1-02.cdn.hygraph.com/content/clorhpxi8qx7r01t6hfp1b5f6/master", + searchParams: [], method: "post", headers: [{ name: "Content-Type", value: "application/json" }], body: { @@ -276,7 +334,8 @@ test("multiline graphql is idempotent", () => { test("support basic http authentication", () => { expect(parseCurl(`curl https://my-url.com -u "user:password"`)).toEqual({ - url: "https://my-url.com", + url: "https://my-url.com/", + searchParams: [], method: "get", headers: [ { diff --git a/apps/builder/app/builder/features/settings-panel/curl.ts b/apps/builder/app/builder/features/settings-panel/curl.ts index 1a6ab9c77bd2..1503095fecce 100644 --- a/apps/builder/app/builder/features/settings-panel/curl.ts +++ b/apps/builder/app/builder/features/settings-panel/curl.ts @@ -26,7 +26,7 @@ const getMethod = (value: string): ResourceRequest["method"] => { export type CurlRequest = Pick< ResourceRequest, - "url" | "method" | "headers" | "body" + "url" | "searchParams" | "method" | "headers" | "body" >; const encodeSearchParams = (data: string[]) => { @@ -84,12 +84,18 @@ export const parseCurl = (curl: string): undefined | CurlRequest => { return; } // curl url - let url = args._[1].toString(); + const url = new URL(args._[1].toString()); const defaultMethod = args.data ? "post" : "get"; const method: CurlRequest["method"] = args.get ? "get" : getMethod(args.request ?? defaultMethod); let contentType: undefined | string; + const searchParams: NonNullable = []; + for (const [name, value] of url.searchParams) { + searchParams.push({ name, value }); + } + // remove all search params from url + url.search = ""; const headers: ResourceRequest["headers"] = ( (args.header as string[]) ?? [] ).map((header) => { @@ -105,9 +111,10 @@ export const parseCurl = (curl: string): undefined | CurlRequest => { } let body: undefined | unknown; if (args.get && args.data) { - const separator = url.includes("?") ? "&" : "?"; - const search = encodeSearchParams(args.data); - url = `${url}${separator}${search}`; + for (const pair of args.data) { + const [name, value = ""] = pair.split("="); + searchParams.push({ name, value }); + } } else if (args.data) { body = args.data[0]; if (contentType === "application/json") { @@ -126,7 +133,8 @@ export const parseCurl = (curl: string): undefined | CurlRequest => { } } return { - url: url as string, + url: url.toString(), + searchParams, method, headers, body, @@ -134,10 +142,11 @@ export const parseCurl = (curl: string): undefined | CurlRequest => { }; export const generateCurl = (request: CurlRequest) => { - const args = [ - `curl ${JSON.stringify(request.url)}`, - `--request ${request.method}`, - ]; + const url = new URL(request.url); + for (const { name, value } of request.searchParams) { + url.searchParams.append(name, value); + } + const args = [`curl ${JSON.stringify(url)}`, `--request ${request.method}`]; for (const header of request.headers) { args.push(`--header "${header.name}: ${header.value}"`); } diff --git a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx index df295ea67bc1..90b78890306e 100644 --- a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx +++ b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx @@ -14,6 +14,7 @@ import { useStore } from "@nanostores/react"; import { DataSources, Resource, + ResourceRequest, type DataSource, type Page, } from "@webstudio-is/sdk"; @@ -28,7 +29,6 @@ import { import { sitemapResourceUrl } from "@webstudio-is/sdk/runtime"; import { Box, - Button, Flex, Grid, InputErrorsTooltip, @@ -36,6 +36,7 @@ import { Label, Select, SmallIconButton, + Text, TextArea, Tooltip, theme, @@ -78,18 +79,23 @@ export const parseResource = ({ name: string; formData: FormData; }) => { - const headerNames = formData.getAll("header-name"); - const headerValues = formData.getAll("header-value"); + const searchParamNames = formData.getAll("search-param-name") as string[]; + const searchParamValues = formData.getAll("search-param-value") as string[]; + const headerNames = formData.getAll("header-name") as string[]; + const headerValues = formData.getAll("header-value") as string[]; return Resource.parse({ id, name, url: formData.get("url"), + searchParams: searchParamNames + .map((name, index) => ({ name, value: searchParamValues[index] })) + .filter((item) => item.name.trim()), method: formData.get("method"), - headers: headerNames.map((name, index) => { - const value = headerValues[index]; - return { name, value }; - }), - body: formData.get("body") ?? undefined, + headers: headerNames + .map((name, index) => ({ name, value: headerValues[index] })) + .filter((item) => item.name.trim()), + // use undefined instead of empty string + body: formData.get("body") || undefined, }); }; @@ -119,7 +125,10 @@ export const UrlField = ({ aliases: Map; scope: Record; value: string; - onChange: (value: string) => void; + onChange: ( + urlExpression: string, + searchParams?: ResourceRequest["searchParams"] + ) => void; onCurlPaste: (curl: CurlRequest) => void; }) => { const urlId = useId(); @@ -163,8 +172,19 @@ export const UrlField = ({ const curl = parseCurl(value); if (curl) { onCurlPaste(curl); - } else { + return; + } + try { + const url = new URL(value); + const searchParams: ResourceRequest["searchParams"] = []; + for (const [name, value] of url.searchParams) { + searchParams.push({ name, value: JSON.stringify(value) }); + } + // remove all search params from url + url.search = ""; // update text value as string literal + onChange(JSON.stringify(url.href), searchParams); + } catch { onChange(JSON.stringify(value)); } }} @@ -210,21 +230,7 @@ export const MethodField = ({ ); }; -const validateHeaderName = (value: string) => - value.trim().length === 0 ? "Header name is required" : ""; - -const validateHeaderValue = (value: string, scope: Record) => { - const evaluatedValue = evaluateExpressionWithinScope(value, scope); - if (typeof evaluatedValue !== "string") { - return "Header value expects a string"; - } - if (evaluatedValue.length === 0) { - return "Header value is required"; - } - return ""; -}; - -const HeaderPair = ({ +const SearchParamPair = ({ aliases, scope, name, @@ -239,126 +245,176 @@ const HeaderPair = ({ onChange: (name: string, value: string) => void; onDelete: () => void; }) => { - const nameId = useId(); - const nameRef = useRef(null); - const [nameError, setNameError] = useState(""); - // revalidate and hide error message - // until validity is checks again - useEffect(() => { - nameRef.current?.setCustomValidity(validateHeaderName(name)); - setNameError(""); - }, [name]); - - const valueId = useId(); - const valueRef = useRef(null); - const [valueError, setValueError] = useState(""); - useEffect(() => { - valueRef.current?.setCustomValidity(validateHeaderValue(value, scope)); - setValueError(""); - }, [value, scope]); - return ( - - + onChange(event.target.value, value)} + /> + + onChange(event.target.value, value)} - // can't use event.currentTarget because InputField - // binds focus events to container instead of input - onBlur={() => nameRef.current?.checkValidity()} - onInvalid={(event) => - setNameError(event.currentTarget.validationMessage) + placeholder="Value" + name="search-param-value-literal" + // expressions with variables cannot be edited + disabled={isLiteralExpression(value) === false} + value={String(evaluateExpressionWithinScope(value, scope))} + // update text value as string literal + onChange={(event) => + onChange(name, JSON.stringify(event.target.value)) } /> - - - - - - - - onChange(name, JSON.stringify(event.target.value)) - } - onBlur={() => valueRef.current?.checkValidity()} - onInvalid={(event) => - setValueError(event.currentTarget.validationMessage) - } - /> - - onChange(name, newValue)} + onRemove={(evaluatedValue) => + onChange(name, JSON.stringify(evaluatedValue)) + } + /> + + } + onClick={onDelete} + /> + + ); +}; + +export const SearchParams = ({ + scope, + aliases, + searchParams, + onChange, +}: { + scope: Record; + aliases: Map; + searchParams: NonNullable; + onChange: (searchParams: NonNullable) => void; +}) => { + return ( + + + + } + onClick={() => { + // use empty string expression as default + const newSearchParams = [ + ...searchParams, + { name: "", value: `""` }, + ]; + onChange(newSearchParams); + }} + /> + + + {searchParams.map((searchParam, index) => ( + onChange(name, newValue)} - onRemove={(evaluatedValue) => - onChange(name, JSON.stringify(evaluatedValue)) - } + name={searchParam.name} + value={searchParam.value} + onChange={(name, value) => { + const newSearchParams = [...searchParams]; + newSearchParams[index] = { name, value }; + onChange(newSearchParams); + }} + onDelete={() => { + const newSearchParams = [...searchParams]; + newSearchParams.splice(index, 1); + onChange(newSearchParams); + }} /> - - + ))} + {searchParams.length === 0 && ( + + No search params + + )} + + + ); +}; - - - - - } - onClick={onDelete} +const HeaderPair = ({ + aliases, + scope, + name, + value, + onChange, + onDelete, +}: { + aliases: Map; + scope: Record; + name: string; + value: string; + onChange: (name: string, value: string) => void; + onDelete: () => void; +}) => { + return ( + + onChange(event.target.value, value)} + /> + + + + onChange(name, JSON.stringify(event.target.value)) + } /> - - - - + onChange(name, newValue)} + onRemove={(evaluatedValue) => + onChange(name, JSON.stringify(evaluatedValue)) + } + /> + + } + onClick={onDelete} + /> ); }; @@ -376,8 +432,19 @@ export const Headers = ({ }) => { return ( - - + + + } + onClick={() => { + // use empty string expression as default + const newHeaders = [...headers, { name: "", value: `""` }]; + onChange(newHeaders); + }} + /> + + {headers.map((header, index) => ( ))} - + {headers.length === 0 && ( + + No headers + + )} ); @@ -659,6 +718,9 @@ export const ResourceForm = forwardRef< const [method, setMethod] = useState( resource?.method ?? "get" ); + const [searchParams, setSearchParams] = useState( + resource?.searchParams ?? [] + ); const [headers, setHeaders] = useState( resource?.headers ?? [] ); @@ -703,15 +765,27 @@ export const ResourceForm = forwardRef< return ( <> + { + setUrl(urlExpression); + if (searchParams) { + setSearchParams((prev) => [...prev, ...searchParams]); + } + }} onCurlPaste={(curl) => { // update all feilds when curl is paste into url field - setUrl(JSON.stringify(curl.url)); setMethod(curl.method); + setUrl(JSON.stringify(curl.url)); + setSearchParams( + (curl.searchParams ?? []).map((header) => ({ + name: header.name, + value: JSON.stringify(header.value), + })) + ); setHeaders( curl.headers.map((header) => ({ name: header.name, @@ -721,7 +795,12 @@ export const ResourceForm = forwardRef< setBody(JSON.stringify(curl.body)); }} /> - + ({ + name, + value: computeExpression(value, values), + })), headers: resource.headers.map(({ name, value }) => ({ name, value: computeExpression(value, values), diff --git a/fixtures/webstudio-features/app/__generated__/[expressions]._index.server.tsx b/fixtures/webstudio-features/app/__generated__/[expressions]._index.server.tsx index b9b9a0a303fe..ae0c9252ac70 100644 --- a/fixtures/webstudio-features/app/__generated__/[expressions]._index.server.tsx +++ b/fixtures/webstudio-features/app/__generated__/[expressions]._index.server.tsx @@ -8,6 +8,7 @@ export const getResources = (_props: { system: System }) => { id: "fjMzCru8O2U31xY2P1Ovr", name: "jsonResourceVariable", url: "https://httpbin.org/get?hello=world", + searchParams: [], method: "get", headers: [], }; diff --git a/fixtures/webstudio-features/app/__generated__/[form]._index.server.tsx b/fixtures/webstudio-features/app/__generated__/[form]._index.server.tsx index 217b14078ea8..f14016ed67db 100644 --- a/fixtures/webstudio-features/app/__generated__/[form]._index.server.tsx +++ b/fixtures/webstudio-features/app/__generated__/[form]._index.server.tsx @@ -8,6 +8,7 @@ export const getResources = (_props: { system: System }) => { id: "isNSM3wXcnHFikwNPlEOL", name: "action", url: "/custom", + searchParams: [], method: "get", headers: [{ name: "Content-Type", value: "application/json" }], }; diff --git a/fixtures/webstudio-features/app/__generated__/[resources]._index.server.tsx b/fixtures/webstudio-features/app/__generated__/[resources]._index.server.tsx index d6eb627d07d3..0a125bf68b15 100644 --- a/fixtures/webstudio-features/app/__generated__/[resources]._index.server.tsx +++ b/fixtures/webstudio-features/app/__generated__/[resources]._index.server.tsx @@ -8,6 +8,7 @@ export const getResources = (_props: { system: System }) => { id: "1vX6SQdaCjJN6MvJlG_cQ", name: "list", url: "https://gist.githubusercontent.com/TrySound/56507c301ec85669db5f1541406a9259/raw/a49548730ab592c86b9e7781f5b29beec4765494/collection.json", + searchParams: [], method: "get", headers: [], }; diff --git a/packages/sdk/src/resource-loader.test.ts b/packages/sdk/src/resource-loader.test.ts index 7f0b9d759fe3..cad243a008c5 100644 --- a/packages/sdk/src/resource-loader.test.ts +++ b/packages/sdk/src/resource-loader.test.ts @@ -23,6 +23,7 @@ describe("loadResource", () => { id: "1", name: "resource", url: "https://example.com/resource", + searchParams: [], method: "get", headers: [], body: undefined, @@ -55,6 +56,7 @@ describe("loadResource", () => { id: "1", name: "resource", url: "https://example.com/resource", + searchParams: [], method: "get", headers: [], body: undefined, @@ -74,4 +76,34 @@ describe("loadResource", () => { statusText: "", }); }); + + test("should fetch resource with search params", async () => { + const mockResponse = new Response(JSON.stringify({ key: "value" }), { + status: 200, + }); + mockFetch.mockResolvedValue(mockResponse); + + const resourceRequest: ResourceRequest = { + id: "1", + name: "resource", + url: "https://example.com/resource", + searchParams: [ + { name: "search", value: "term1" }, + { name: "search", value: "term2" }, + { name: "filter", value: "привет" }, + ], + method: "get", + headers: [], + }; + + await loadResource(mockFetch, resourceRequest); + + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/resource?search=term1&search=term2&filter=%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82", + { + method: "get", + headers: new Headers(), + } + ); + }); }); diff --git a/packages/sdk/src/resource-loader.ts b/packages/sdk/src/resource-loader.ts index 53422de21bb1..abfd0827fe3a 100644 --- a/packages/sdk/src/resource-loader.ts +++ b/packages/sdk/src/resource-loader.ts @@ -22,7 +22,15 @@ export const loadResource = async ( customFetch: typeof fetch, resourceRequest: ResourceRequest ) => { - const { url, method, headers, body } = resourceRequest; + const { method, searchParams, headers, body } = resourceRequest; + // cloudflare workers fail when fetching url contains spaces + // even though new URL suppose to trim them on parsing by spec + const url = new URL(resourceRequest.url.trim()); + if (searchParams) { + for (const { name, value } of searchParams) { + url.searchParams.append(name, value); + } + } const requestHeaders = new Headers( headers.map(({ name, value }): [string, string] => [name, value]) ); @@ -39,9 +47,7 @@ export const loadResource = async ( } } try { - // cloudflare workers fail when fetching url contains spaces - // even though new URL suppose to trim them on parsing by spec - const response = await customFetch(url.trim(), requestInit); + const response = await customFetch(url.href, requestInit); let data = await response.text(); diff --git a/packages/sdk/src/resources-generator.test.tsx b/packages/sdk/src/resources-generator.test.tsx index 28dc27d132b3..166fbeeb7620 100644 --- a/packages/sdk/src/resources-generator.test.tsx +++ b/packages/sdk/src/resources-generator.test.tsx @@ -11,6 +11,7 @@ import { generateResources, replaceFormActionsWithResources, } from "./resources-generator"; +import type { DataSource } from "./schema/data-sources"; const toMap = (list: T[]) => new Map(list.map((item) => [item.id, item] as const)); @@ -48,6 +49,8 @@ test("generate resources loader", () => { id: "resourceId", name: "resourceName", url: "https://my-json.com", + searchParams: [ + ], method: "post", headers: [ { name: "Content-Type", value: "application/json" }, @@ -113,6 +116,8 @@ test("generate variable and use in resources loader", () => { id: "resourceId", name: "resourceName", url: "https://my-json.com/", + searchParams: [ + ], method: "post", headers: [ { name: "Authorization", value: "Token " + AccessToken }, @@ -158,6 +163,7 @@ test("generate page system variable and use in resources loader", () => { id: "resourceId", name: "resourceName", url: `"https://my-json.com/" + $ws$dataSource$variableSystemId.params.slug`, + searchParams: [], method: "post", headers: [{ name: "Content-Type", value: `"application/json"` }], body: `{ body: true }`, @@ -173,6 +179,8 @@ test("generate page system variable and use in resources loader", () => { id: "resourceId", name: "resourceName", url: "https://my-json.com/" + system?.params?.slug, + searchParams: [ + ], method: "post", headers: [ { name: "Content-Type", value: "application/json" }, @@ -215,6 +223,8 @@ test("generate global system variable and use in resources loader", () => { id: "resource:0", name: "My Resource", url: "https://my-json.com/" + system?.params?.slug, + searchParams: [ + ], method: "post", headers: [ { name: "Content-Type", value: "application/json" }, @@ -254,6 +264,70 @@ test("generate empty resources loader", () => { `); }); +test("generate resource loader with search params", () => { + expect( + generateResources({ + scope: createScope(), + page: { rootInstanceId: "body" } as Page, + dataSources: toMap([ + { + id: "variableTermId", + scopeInstanceId: "body", + type: "variable", + name: "term", + value: { type: "string", value: "my-term" }, + }, + { + id: "variableResourceId", + scopeInstanceId: "body", + type: "resource", + name: "variableName", + resourceId: "resourceId", + }, + ]), + resources: toMap([ + { + id: "resourceId", + name: "resourceName", + method: "get", + url: `"https://my-json.com"`, + searchParams: [ + { + name: "search", + value: `$ws$dataSource$variableTermId`, + }, + ], + headers: [], + }, + ]), + props: new Map(), + }) + ).toMatchInlineSnapshot(` + "import type { System, ResourceRequest } from "@webstudio-is/sdk"; + export const getResources = (_props: { system: System }) => { + let term = "my-term" + const resourceName: ResourceRequest = { + id: "resourceId", + name: "resourceName", + url: "https://my-json.com", + searchParams: [ + { name: "search", value: term }, + ], + method: "get", + headers: [ + ], + } + const _data = new Map([ + ["resourceName", resourceName], + ]) + const _action = new Map([ + ]) + return { data: _data, action: _action } + } + " + `); +}); + test("prevent generating unused variables", () => { expect( generateResources({ @@ -351,6 +425,8 @@ test("generate action resource", () => { id: "resourceId", name: "resourceName", url: "https://my-url.com", + searchParams: [ + ], method: "post", headers: [ ], diff --git a/packages/sdk/src/resources-generator.ts b/packages/sdk/src/resources-generator.ts index d7871e3ac52b..14dce06f7dba 100644 --- a/packages/sdk/src/resources-generator.ts +++ b/packages/sdk/src/resources-generator.ts @@ -36,6 +36,17 @@ export const generateResources = ({ scope, }); generatedRequest += ` url: ${url},\n`; + generatedRequest += ` searchParams: [\n`; + for (const searchParam of resource.searchParams ?? []) { + const value = generateExpression({ + expression: searchParam.value, + dataSources, + usedDataSources, + scope, + }); + generatedRequest += ` { name: "${searchParam.name}", value: ${value} },\n`; + } + generatedRequest += ` ],\n`; generatedRequest += ` method: "${resource.method}",\n`; generatedRequest += ` headers: [\n`; for (const header of resource.headers) { diff --git a/packages/sdk/src/schema/resources.ts b/packages/sdk/src/schema/resources.ts index 8f9a39c3e9dd..20d4cebb550a 100644 --- a/packages/sdk/src/schema/resources.ts +++ b/packages/sdk/src/schema/resources.ts @@ -9,6 +9,12 @@ const Method = z.union([ z.literal("delete"), ]); +const SearchParam = z.object({ + name: z.string(), + // expression + value: z.string(), +}); + const Header = z.object({ name: z.string(), // expression @@ -22,6 +28,7 @@ export const Resource = z.object({ method: Method, // expression url: z.string(), + searchParams: z.array(SearchParam).optional(), headers: z.array(Header), // expression body: z.optional(z.string()), @@ -35,6 +42,7 @@ export const ResourceRequest = z.object({ name: z.string(), method: Method, url: z.string(), + searchParams: z.array(SearchParam), headers: z.array(Header), body: z.optional(z.unknown()), });