Skip to content

Commit 91588c9

Browse files
authored
feat: support json in resource search params and headers (#5387)
Ref #3691 This should improve experience when working with baserow filters or similar backends. <img width="641" height="347" alt="Screenshot 2025-09-01 at 12 19 35" src="https://github.com/user-attachments/assets/7be1e967-729a-4025-9d4e-112c8e4162ad" />
1 parent d626b8b commit 91588c9

File tree

7 files changed

+157
-45
lines changed

7 files changed

+157
-45
lines changed

apps/builder/app/builder/features/settings-panel/curl.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,39 @@ test("generate curl with search params", () => {
310310
`);
311311
});
312312

313+
test("generate curl with JSON search params", () => {
314+
expect(
315+
generateCurl({
316+
url: "https://my-url.com",
317+
searchParams: [
318+
{ name: "filter", value: { type: "AND", left: true, right: false } },
319+
],
320+
method: "get",
321+
headers: [],
322+
})
323+
).toMatchInlineSnapshot(`
324+
"curl "https://my-url.com/?filter=%7B%22type%22%3A%22AND%22%2C%22left%22%3Atrue%2C%22right%22%3Afalse%7D" \\
325+
--request get"
326+
`);
327+
});
328+
329+
test("generate curl with JSON headers", () => {
330+
expect(
331+
generateCurl({
332+
url: "https://my-url.com",
333+
searchParams: [],
334+
method: "get",
335+
headers: [
336+
{ name: "x-filter", value: { type: "AND", left: true, right: false } },
337+
],
338+
})
339+
).toMatchInlineSnapshot(`
340+
"curl "https://my-url.com/" \\
341+
--request get \\
342+
--header "x-filter: {\\"type\\":\\"AND\\",\\"left\\":true,\\"right\\":false}""
343+
`);
344+
});
345+
313346
test("multiline graphql is idempotent", () => {
314347
const request: CurlRequest = {
315348
url: "https://eu-central-1-shared-euc1-02.cdn.hygraph.com/content/clorhpxi8qx7r01t6hfp1b5f6/master",

apps/builder/app/builder/features/settings-panel/curl.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { ResourceRequest } from "@webstudio-is/sdk";
21
import { tokenizeArgs } from "args-tokenizer";
32
import { parse as parseArgs } from "@bomb.sh/args";
3+
import type { ResourceRequest } from "@webstudio-is/sdk";
4+
import { serializeValue } from "@webstudio-is/sdk/runtime";
45

56
/*
67
@@ -144,11 +145,14 @@ export const parseCurl = (curl: string): undefined | CurlRequest => {
144145
export const generateCurl = (request: CurlRequest) => {
145146
const url = new URL(request.url);
146147
for (const { name, value } of request.searchParams) {
147-
url.searchParams.append(name, value);
148+
url.searchParams.append(name, serializeValue(value));
148149
}
149150
const args = [`curl ${JSON.stringify(url)}`, `--request ${request.method}`];
150151
for (const header of request.headers) {
151-
args.push(`--header "${header.name}: ${header.value}"`);
152+
args.push(
153+
// escape json in headers
154+
`--header "${header.name}: ${serializeValue(header.value).replaceAll('"', '\\"')}"`
155+
);
152156
}
153157
if (request.body) {
154158
let body = request.body;

apps/builder/app/builder/features/settings-panel/resource-panel.tsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { useStore } from "@nanostores/react";
1414
import {
1515
DataSources,
1616
Resource,
17-
ResourceRequest,
1817
type DataSource,
1918
type Page,
2019
} from "@webstudio-is/sdk";
@@ -26,7 +25,7 @@ import {
2625
SYSTEM_VARIABLE_ID,
2726
systemParameter,
2827
} from "@webstudio-is/sdk";
29-
import { sitemapResourceUrl } from "@webstudio-is/sdk/runtime";
28+
import { serializeValue, sitemapResourceUrl } from "@webstudio-is/sdk/runtime";
3029
import {
3130
Box,
3231
Flex,
@@ -127,7 +126,7 @@ export const UrlField = ({
127126
value: string;
128127
onChange: (
129128
urlExpression: string,
130-
searchParams?: ResourceRequest["searchParams"]
129+
searchParams?: Resource["searchParams"]
131130
) => void;
132131
onCurlPaste: (curl: CurlRequest) => void;
133132
}) => {
@@ -176,7 +175,7 @@ export const UrlField = ({
176175
}
177176
try {
178177
const url = new URL(value);
179-
const searchParams: ResourceRequest["searchParams"] = [];
178+
const searchParams: Resource["searchParams"] = [];
180179
for (const [name, value] of url.searchParams) {
181180
searchParams.push({ name, value: JSON.stringify(value) });
182181
}
@@ -245,6 +244,10 @@ const SearchParamPair = ({
245244
onChange: (name: string, value: string) => void;
246245
onDelete: () => void;
247246
}) => {
247+
const evaluatedValue = evaluateExpressionWithinScope(value, scope);
248+
// expressions with variables or objects cannot be edited from input
249+
const isValueUnbound =
250+
isLiteralExpression(value) && typeof evaluatedValue === "string";
248251
return (
249252
<Grid
250253
gap={2}
@@ -259,19 +262,13 @@ const SearchParamPair = ({
259262
value={name}
260263
onChange={(event) => onChange(event.target.value, value)}
261264
/>
262-
<input
263-
hidden={true}
264-
readOnly={true}
265-
name="search-param-value"
266-
value={value}
267-
/>
265+
<input type="hidden" name="search-param-value" value={value} />
268266
<BindingControl>
269267
<InputField
270268
placeholder="Value"
271269
name="search-param-value-literal"
272-
// expressions with variables cannot be edited
273-
disabled={isLiteralExpression(value) === false}
274-
value={String(evaluateExpressionWithinScope(value, scope))}
270+
disabled={!isValueUnbound}
271+
value={serializeValue(evaluatedValue)}
275272
// update text value as string literal
276273
onChange={(event) =>
277274
onChange(name, JSON.stringify(event.target.value))
@@ -280,7 +277,7 @@ const SearchParamPair = ({
280277
<BindingPopover
281278
scope={scope}
282279
aliases={aliases}
283-
variant={isLiteralExpression(value) ? "default" : "bound"}
280+
variant={isValueUnbound ? "default" : "bound"}
284281
value={value}
285282
onChange={(newValue) => onChange(name, newValue)}
286283
onRemove={(evaluatedValue) =>
@@ -371,6 +368,10 @@ const HeaderPair = ({
371368
onChange: (name: string, value: string) => void;
372369
onDelete: () => void;
373370
}) => {
371+
const evaluatedValue = evaluateExpressionWithinScope(value, scope);
372+
// expressions with variables or objects cannot be edited from input
373+
const isValueUnbound =
374+
isLiteralExpression(value) && typeof evaluatedValue === "string";
374375
return (
375376
<Grid
376377
gap={2}
@@ -390,9 +391,8 @@ const HeaderPair = ({
390391
<InputField
391392
placeholder="Value"
392393
name="header-value-validator"
393-
// expressions with variables cannot be edited
394-
disabled={isLiteralExpression(value) === false}
395-
value={String(evaluateExpressionWithinScope(value, scope))}
394+
disabled={!isValueUnbound}
395+
value={serializeValue(evaluatedValue)}
396396
// update text value as string literal
397397
onChange={(event) =>
398398
onChange(name, JSON.stringify(event.target.value))
@@ -401,7 +401,7 @@ const HeaderPair = ({
401401
<BindingPopover
402402
scope={scope}
403403
aliases={aliases}
404-
variant={isLiteralExpression(value) ? "default" : "bound"}
404+
variant={isValueUnbound ? "default" : "bound"}
405405
value={value}
406406
onChange={(newValue) => onChange(name, newValue)}
407407
onRemove={(evaluatedValue) =>

packages/sdk/src/resource-loader.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,59 @@ describe("loadResource", () => {
106106
}
107107
);
108108
});
109+
110+
test("should fetch resource with JSON search params", async () => {
111+
const mockResponse = new Response(JSON.stringify({ key: "value" }), {
112+
status: 200,
113+
});
114+
mockFetch.mockResolvedValue(mockResponse);
115+
116+
const resourceRequest: ResourceRequest = {
117+
id: "1",
118+
name: "resource",
119+
url: "https://example.com/resource",
120+
searchParams: [
121+
{ name: "filter", value: { type: "AND", left: "a", right: "b" } },
122+
],
123+
method: "get",
124+
headers: [],
125+
};
126+
127+
await loadResource(mockFetch, resourceRequest);
128+
129+
expect(mockFetch).toHaveBeenCalledWith(
130+
"https://example.com/resource?filter=%7B%22type%22%3A%22AND%22%2C%22left%22%3A%22a%22%2C%22right%22%3A%22b%22%7D",
131+
{
132+
method: "get",
133+
headers: new Headers(),
134+
}
135+
);
136+
});
137+
138+
test("should fetch resource with JSON headers", async () => {
139+
const mockResponse = new Response(JSON.stringify({ key: "value" }), {
140+
status: 200,
141+
});
142+
mockFetch.mockResolvedValue(mockResponse);
143+
144+
const resourceRequest: ResourceRequest = {
145+
id: "1",
146+
name: "resource",
147+
url: "https://example.com/resource",
148+
searchParams: [],
149+
method: "get",
150+
headers: [
151+
{ name: "filter", value: { type: "AND", left: "a", right: "b" } },
152+
],
153+
};
154+
155+
await loadResource(mockFetch, resourceRequest);
156+
157+
expect(mockFetch).toHaveBeenCalledWith("https://example.com/resource", {
158+
method: "get",
159+
headers: new Headers([
160+
["filter", '{"type":"AND","left":"a","right":"b"}'],
161+
]),
162+
});
163+
});
109164
});

packages/sdk/src/resource-loader.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import hash from "@emotion/hash";
22
import type { ResourceRequest } from "./schema/resources";
3+
import { serializeValue } from "./to-string";
34

45
const LOCAL_RESOURCE_PREFIX = "$resources";
56

@@ -28,23 +29,21 @@ export const loadResource = async (
2829
const url = new URL(resourceRequest.url.trim());
2930
if (searchParams) {
3031
for (const { name, value } of searchParams) {
31-
url.searchParams.append(name, value);
32+
url.searchParams.append(name, serializeValue(value));
3233
}
3334
}
3435
const requestHeaders = new Headers(
35-
headers.map(({ name, value }): [string, string] => [name, value])
36+
headers.map(({ name, value }): [string, string] => [
37+
name,
38+
serializeValue(value),
39+
])
3640
);
3741
const requestInit: RequestInit = {
3842
method,
3943
headers: requestHeaders,
4044
};
4145
if (method !== "get" && body !== undefined) {
42-
if (typeof body === "string") {
43-
requestInit.body = body;
44-
}
45-
if (typeof body === "object") {
46-
requestInit.body = JSON.stringify(body);
47-
}
46+
requestInit.body = serializeValue(body);
4847
}
4948
try {
5049
const response = await customFetch(url.href, requestInit);

packages/sdk/src/schema/resources.ts

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,29 @@ const Method = z.union([
99
z.literal("delete"),
1010
]);
1111

12-
const SearchParam = z.object({
13-
name: z.string(),
14-
// expression
15-
value: z.string(),
16-
});
17-
18-
const Header = z.object({
19-
name: z.string(),
20-
// expression
21-
value: z.string(),
22-
});
23-
2412
export const Resource = z.object({
2513
id: ResourceId,
2614
name: z.string(),
2715
control: z.optional(z.union([z.literal("system"), z.literal("graphql")])),
2816
method: Method,
2917
// expression
3018
url: z.string(),
31-
searchParams: z.array(SearchParam).optional(),
32-
headers: z.array(Header),
19+
searchParams: z
20+
.array(
21+
z.object({
22+
name: z.string(),
23+
// expression
24+
value: z.string(),
25+
})
26+
)
27+
.optional(),
28+
headers: z.array(
29+
z.object({
30+
name: z.string(),
31+
// expression
32+
value: z.string(),
33+
})
34+
),
3335
// expression
3436
body: z.optional(z.string()),
3537
});
@@ -42,8 +44,20 @@ export const ResourceRequest = z.object({
4244
name: z.string(),
4345
method: Method,
4446
url: z.string(),
45-
searchParams: z.array(SearchParam),
46-
headers: z.array(Header),
47+
searchParams: z.array(
48+
z.object({
49+
name: z.string(),
50+
// can be string or object which should be serialized
51+
value: z.unknown(),
52+
})
53+
),
54+
headers: z.array(
55+
z.object({
56+
name: z.string(),
57+
// can be string or object which should be serialized
58+
value: z.unknown(),
59+
})
60+
),
4761
body: z.optional(z.unknown()),
4862
});
4963

packages/sdk/src/to-string.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,10 @@ export const isPlainObject = (value: unknown): value is object => {
2525
Object.getPrototypeOf(value) === Object.prototype)
2626
);
2727
};
28+
29+
export const serializeValue = (value: unknown) => {
30+
if (typeof value === "string") {
31+
return value;
32+
}
33+
return JSON.stringify(value);
34+
};

0 commit comments

Comments
 (0)