From 35432e00e257f548d2fa43a4ed0e9328c6e2d9ec Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Mon, 1 Sep 2025 12:38:20 +0200 Subject: [PATCH 1/2] feat: support json in resource search params and headers This should improve experience when working with baserow filters or similar backends. --- .../features/settings-panel/curl.test.ts | 33 +++++++++++ .../builder/features/settings-panel/curl.ts | 10 +++- .../settings-panel/resource-panel.tsx | 36 ++++++------ packages/sdk/src/resource-loader.test.ts | 55 +++++++++++++++++++ packages/sdk/src/resource-loader.ts | 15 +++-- packages/sdk/src/schema/resources.ts | 46 ++++++++++------ packages/sdk/src/to-string.ts | 7 +++ 7 files changed, 157 insertions(+), 45 deletions(-) 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 6328ca3dbfe0..0f9ce556feaf 100644 --- a/apps/builder/app/builder/features/settings-panel/curl.test.ts +++ b/apps/builder/app/builder/features/settings-panel/curl.test.ts @@ -310,6 +310,39 @@ test("generate curl with search params", () => { `); }); +test("generate curl with JSON search params", () => { + expect( + generateCurl({ + url: "https://my-url.com", + searchParams: [ + { name: "filter", value: { type: "AND", left: true, right: false } }, + ], + method: "get", + headers: [], + }) + ).toMatchInlineSnapshot(` +"curl "https://my-url.com/?filter=%7B%22type%22%3A%22AND%22%2C%22left%22%3Atrue%2C%22right%22%3Afalse%7D" \\ + --request get" +`); +}); + +test("generate curl with JSON headers", () => { + expect( + generateCurl({ + url: "https://my-url.com", + searchParams: [], + method: "get", + headers: [ + { name: "x-filter", value: { type: "AND", left: true, right: false } }, + ], + }) + ).toMatchInlineSnapshot(` +"curl "https://my-url.com/" \\ + --request get \\ + --header "x-filter: {\\"type\\":\\"AND\\",\\"left\\":true,\\"right\\":false}"" +`); +}); + test("multiline graphql is idempotent", () => { const request: CurlRequest = { url: "https://eu-central-1-shared-euc1-02.cdn.hygraph.com/content/clorhpxi8qx7r01t6hfp1b5f6/master", diff --git a/apps/builder/app/builder/features/settings-panel/curl.ts b/apps/builder/app/builder/features/settings-panel/curl.ts index 1503095fecce..a36d9403eb30 100644 --- a/apps/builder/app/builder/features/settings-panel/curl.ts +++ b/apps/builder/app/builder/features/settings-panel/curl.ts @@ -1,6 +1,7 @@ -import type { ResourceRequest } from "@webstudio-is/sdk"; import { tokenizeArgs } from "args-tokenizer"; import { parse as parseArgs } from "@bomb.sh/args"; +import type { ResourceRequest } from "@webstudio-is/sdk"; +import { serializeValue } from "@webstudio-is/sdk/runtime"; /* @@ -144,11 +145,14 @@ export const parseCurl = (curl: string): undefined | CurlRequest => { export const generateCurl = (request: CurlRequest) => { const url = new URL(request.url); for (const { name, value } of request.searchParams) { - url.searchParams.append(name, value); + url.searchParams.append(name, serializeValue(value)); } const args = [`curl ${JSON.stringify(url)}`, `--request ${request.method}`]; for (const header of request.headers) { - args.push(`--header "${header.name}: ${header.value}"`); + args.push( + // escape json in headers + `--header "${header.name}: ${serializeValue(header.value).replaceAll('"', '\\"')}"` + ); } if (request.body) { let body = request.body; 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 90b78890306e..1f32e44cd530 100644 --- a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx +++ b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx @@ -14,7 +14,6 @@ import { useStore } from "@nanostores/react"; import { DataSources, Resource, - ResourceRequest, type DataSource, type Page, } from "@webstudio-is/sdk"; @@ -26,7 +25,7 @@ import { SYSTEM_VARIABLE_ID, systemParameter, } from "@webstudio-is/sdk"; -import { sitemapResourceUrl } from "@webstudio-is/sdk/runtime"; +import { serializeValue, sitemapResourceUrl } from "@webstudio-is/sdk/runtime"; import { Box, Flex, @@ -127,7 +126,7 @@ export const UrlField = ({ value: string; onChange: ( urlExpression: string, - searchParams?: ResourceRequest["searchParams"] + searchParams?: Resource["searchParams"] ) => void; onCurlPaste: (curl: CurlRequest) => void; }) => { @@ -176,7 +175,7 @@ export const UrlField = ({ } try { const url = new URL(value); - const searchParams: ResourceRequest["searchParams"] = []; + const searchParams: Resource["searchParams"] = []; for (const [name, value] of url.searchParams) { searchParams.push({ name, value: JSON.stringify(value) }); } @@ -245,6 +244,10 @@ const SearchParamPair = ({ onChange: (name: string, value: string) => void; onDelete: () => void; }) => { + const evaluatedValue = evaluateExpressionWithinScope(value, scope); + // expressions with variables or objects cannot be edited from input + const isValueUnbound = + isLiteralExpression(value) && typeof evaluatedValue === "string"; return ( onChange(event.target.value, value)} /> - + onChange(name, JSON.stringify(event.target.value)) @@ -280,7 +277,7 @@ const SearchParamPair = ({ onChange(name, newValue)} onRemove={(evaluatedValue) => @@ -371,6 +368,10 @@ const HeaderPair = ({ onChange: (name: string, value: string) => void; onDelete: () => void; }) => { + const evaluatedValue = evaluateExpressionWithinScope(value, scope); + // expressions with variables or objects cannot be edited from input + const isValueUnbound = + isLiteralExpression(value) && typeof evaluatedValue === "string"; return ( onChange(name, JSON.stringify(event.target.value)) @@ -401,7 +401,7 @@ const HeaderPair = ({ onChange(name, newValue)} onRemove={(evaluatedValue) => diff --git a/packages/sdk/src/resource-loader.test.ts b/packages/sdk/src/resource-loader.test.ts index cad243a008c5..b6d163424d46 100644 --- a/packages/sdk/src/resource-loader.test.ts +++ b/packages/sdk/src/resource-loader.test.ts @@ -106,4 +106,59 @@ describe("loadResource", () => { } ); }); + + test("should fetch resource with JSON 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: "filter", value: { type: "AND", left: "a", right: "b" } }, + ], + method: "get", + headers: [], + }; + + await loadResource(mockFetch, resourceRequest); + + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/resource?filter=%7B%22type%22%3A%22AND%22%2C%22left%22%3A%22a%22%2C%22right%22%3A%22b%22%7D", + { + method: "get", + headers: new Headers(), + } + ); + }); + + test("should fetch resource with JSON headers", 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: [], + method: "get", + headers: [ + { name: "filter", value: { type: "AND", left: "a", right: "b" } }, + ], + }; + + await loadResource(mockFetch, resourceRequest); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/resource", { + method: "get", + headers: new Headers([ + ["filter", '{"type":"AND","left":"a","right":"b"}'], + ]), + }); + }); }); diff --git a/packages/sdk/src/resource-loader.ts b/packages/sdk/src/resource-loader.ts index abfd0827fe3a..75dfae3c4fe6 100644 --- a/packages/sdk/src/resource-loader.ts +++ b/packages/sdk/src/resource-loader.ts @@ -1,5 +1,6 @@ import hash from "@emotion/hash"; import type { ResourceRequest } from "./schema/resources"; +import { serializeValue } from "./to-string"; const LOCAL_RESOURCE_PREFIX = "$resources"; @@ -28,23 +29,21 @@ export const loadResource = async ( const url = new URL(resourceRequest.url.trim()); if (searchParams) { for (const { name, value } of searchParams) { - url.searchParams.append(name, value); + url.searchParams.append(name, serializeValue(value)); } } const requestHeaders = new Headers( - headers.map(({ name, value }): [string, string] => [name, value]) + headers.map(({ name, value }): [string, string] => [ + name, + serializeValue(value), + ]) ); const requestInit: RequestInit = { method, headers: requestHeaders, }; if (method !== "get" && body !== undefined) { - if (typeof body === "string") { - requestInit.body = body; - } - if (typeof body === "object") { - requestInit.body = JSON.stringify(body); - } + requestInit.body = serializeValue(body); } try { const response = await customFetch(url.href, requestInit); diff --git a/packages/sdk/src/schema/resources.ts b/packages/sdk/src/schema/resources.ts index 20d4cebb550a..f51eaca7cf85 100644 --- a/packages/sdk/src/schema/resources.ts +++ b/packages/sdk/src/schema/resources.ts @@ -9,18 +9,6 @@ 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 - value: z.string(), -}); - export const Resource = z.object({ id: ResourceId, name: z.string(), @@ -28,8 +16,22 @@ export const Resource = z.object({ method: Method, // expression url: z.string(), - searchParams: z.array(SearchParam).optional(), - headers: z.array(Header), + searchParams: z + .array( + z.object({ + name: z.string(), + // expression + value: z.string(), + }) + ) + .optional(), + headers: z.array( + z.object({ + name: z.string(), + // expression + value: z.string(), + }) + ), // expression body: z.optional(z.string()), }); @@ -42,8 +44,20 @@ export const ResourceRequest = z.object({ name: z.string(), method: Method, url: z.string(), - searchParams: z.array(SearchParam), - headers: z.array(Header), + searchParams: z.array( + z.object({ + name: z.string(), + // can be string or object which should be serialized + value: z.unknown(), + }) + ), + headers: z.array( + z.object({ + name: z.string(), + // can be string or object which should be serialized + value: z.unknown(), + }) + ), body: z.optional(z.unknown()), }); diff --git a/packages/sdk/src/to-string.ts b/packages/sdk/src/to-string.ts index ddcf8698309b..fc81083a047c 100644 --- a/packages/sdk/src/to-string.ts +++ b/packages/sdk/src/to-string.ts @@ -25,3 +25,10 @@ export const isPlainObject = (value: unknown): value is object => { Object.getPrototypeOf(value) === Object.prototype) ); }; + +export const serializeValue = (value: unknown) => { + if (typeof value === "string") { + return value; + } + return JSON.stringify(value); +}; From e13ccd0ab389f838147b49c881103a5e439e1377 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Mon, 1 Sep 2025 12:47:09 +0200 Subject: [PATCH 2/2] Trigger rebuild