From e7a0a6077e00d6d2491a1b933ba1ed342270257b Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 4 Jul 2026 18:51:05 -0700 Subject: [PATCH] Convert request bodies when changing type --- .../components/HttpRequestPane.tsx | 10 +- .../components/graphql/GraphQLEditor.tsx | 21 +- .../lib/requestBodyConversion.test.ts | 103 ++++++++ apps/yaak-client/lib/requestBodyConversion.ts | 224 ++++++++++++++++++ 4 files changed, 338 insertions(+), 20 deletions(-) create mode 100644 apps/yaak-client/lib/requestBodyConversion.test.ts create mode 100644 apps/yaak-client/lib/requestBodyConversion.ts diff --git a/apps/yaak-client/components/HttpRequestPane.tsx b/apps/yaak-client/components/HttpRequestPane.tsx index cd5668ba..944c4904 100644 --- a/apps/yaak-client/components/HttpRequestPane.tsx +++ b/apps/yaak-client/components/HttpRequestPane.tsx @@ -20,6 +20,7 @@ import { deepEqualAtom } from "../lib/atoms"; import { languageFromContentType } from "../lib/contentType"; import { generateId } from "../lib/generateId"; import { extractPathPlaceholders } from "../lib/pathPlaceholders"; +import { convertRequestBody } from "../lib/requestBodyConversion"; import { BODY_TYPE_BINARY, BODY_TYPE_FORM_MULTIPART, @@ -195,7 +196,14 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: }); }; - const patch: Partial = { bodyType }; + const patch: Partial = { + bodyType, + body: convertRequestBody({ + body: activeRequest.body, + fromBodyType: activeRequest.bodyType, + toBodyType: bodyType, + }), + }; let newContentType: string | null | undefined; if (bodyType === BODY_TYPE_NONE) { newContentType = null; diff --git a/apps/yaak-client/components/graphql/GraphQLEditor.tsx b/apps/yaak-client/components/graphql/GraphQLEditor.tsx index e66fc41e..820ee303 100644 --- a/apps/yaak-client/components/graphql/GraphQLEditor.tsx +++ b/apps/yaak-client/components/graphql/GraphQLEditor.tsx @@ -14,6 +14,7 @@ import { Editor } from "../core/Editor/LazyEditor"; import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui"; import { Separator } from "../core/Separator"; import { tryFormatGraphql } from "../../lib/formatters"; +import { normalizeGraphQLBody } from "../../lib/requestBodyConversion"; import { showGraphQLDocExplorerAtom } from "./graphqlAtoms"; type Props = Pick & { @@ -38,17 +39,7 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp const [currentBody, setCurrentBody] = useStateWithDeps<{ query: string; variables: string | undefined; - }>(() => { - // Migrate text bodies to GraphQL format - // NOTE: This is how GraphQL used to be stored - if ("text" in request.body) { - const b = tryParseJson(request.body.text, {}); - const variables = JSON.stringify(b.variables || undefined, null, 2); - return { query: b.query ?? "", variables }; - } - - return { query: request.body.query ?? "", variables: request.body.variables ?? "" }; - }, [extraEditorProps.forceUpdateKey]); + }>(() => normalizeGraphQLBody(request.body), [extraEditorProps.forceUpdateKey]); const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom); const isDocOpen = isDocOpenRecord[request.id] !== undefined; @@ -236,11 +227,3 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp ); } - -function tryParseJson(text: string, fallback: unknown) { - try { - return JSON.parse(text); - } catch { - return fallback; - } -} diff --git a/apps/yaak-client/lib/requestBodyConversion.test.ts b/apps/yaak-client/lib/requestBodyConversion.test.ts new file mode 100644 index 00000000..c6e938bf --- /dev/null +++ b/apps/yaak-client/lib/requestBodyConversion.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_GRAPHQL, + BODY_TYPE_JSON, + BODY_TYPE_OTHER, + BODY_TYPE_XML, +} from "./model_util"; +import { convertRequestBody } from "./requestBodyConversion"; + +describe("convertRequestBody", () => { + test("converts imported JSON GraphQL bodies to GraphQL shape", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_JSON, + toBodyType: BODY_TYPE_GRAPHQL, + body: { + text: JSON.stringify({ + query: "query GetUser($id: ID!) { user(id: $id) { name } }", + variables: { id: "123" }, + }), + }, + }); + + expect(body).toEqual({ + query: "query GetUser($id: ID!) { user(id: $id) { name } }", + variables: '{\n "id": "123"\n}', + }); + }); + + test("converts GraphQL bodies to JSON text", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_GRAPHQL, + toBodyType: BODY_TYPE_JSON, + body: { + query: "query GetUser($id: ID!) { user(id: $id) { name } }", + variables: '{ "id": "123" }', + }, + }); + + expect(body).toEqual({ + text: JSON.stringify( + { + query: "query GetUser($id: ID!) { user(id: $id) { name } }", + variables: { id: "123" }, + }, + null, + 2, + ), + }); + }); + + test("converts urlencoded forms to urlencoded text for text-like bodies", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_FORM_URLENCODED, + toBodyType: BODY_TYPE_OTHER, + body: { + form: [ + { enabled: true, name: "basic", value: "aaa" }, + { enabled: true, name: "funky stuff", value: "*)%&#$)@ *$#)@&" }, + { enabled: false, name: "disabled", value: "hidden" }, + { enabled: true, name: "", value: "unnamed" }, + ], + }, + }); + + expect(body).toEqual({ + text: "basic=aaa&funky+stuff=*%29%25%26%23%24%29%40+*%24%23%29%40%26", + }); + }); + + test("converts urlencoded forms to JSON text for JSON bodies", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_FORM_URLENCODED, + toBodyType: BODY_TYPE_JSON, + body: { + form: [ + { enabled: true, name: "tag", value: "one" }, + { enabled: true, name: "tag", value: "two" }, + { enabled: true, name: "limit", value: "10" }, + ], + }, + }); + + expect(body).toEqual({ + text: JSON.stringify({ tag: ["one", "two"], limit: "10" }, null, 2), + }); + }); + + test("converts urlencoded text to form pairs", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_XML, + toBodyType: BODY_TYPE_FORM_URLENCODED, + body: { text: "a=1&b=two+words" }, + }); + + expect(body).toEqual({ + form: [ + { enabled: true, name: "a", value: "1" }, + { enabled: true, name: "b", value: "two words" }, + ], + }); + }); +}); diff --git a/apps/yaak-client/lib/requestBodyConversion.ts b/apps/yaak-client/lib/requestBodyConversion.ts new file mode 100644 index 00000000..2a912de7 --- /dev/null +++ b/apps/yaak-client/lib/requestBodyConversion.ts @@ -0,0 +1,224 @@ +import type { HttpRequest } from "@yaakapp-internal/models"; +import { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_MULTIPART, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_GRAPHQL, + BODY_TYPE_JSON, + BODY_TYPE_NONE, +} from "./model_util"; + +type Body = HttpRequest["body"]; +type BodyType = HttpRequest["bodyType"]; +type GraphQLBody = { + query: string; + variables: string | undefined; +}; + +export function convertRequestBody({ + body, + fromBodyType, + toBodyType, +}: { + body: Body; + fromBodyType: BodyType; + toBodyType: BodyType; +}): Body { + if (toBodyType === BODY_TYPE_NONE) { + return {}; + } + + if (toBodyType === BODY_TYPE_GRAPHQL) { + return toGraphQLBody(body); + } + + if (toBodyType === BODY_TYPE_FORM_URLENCODED || toBodyType === BODY_TYPE_FORM_MULTIPART) { + return toFormBody(body); + } + + if (toBodyType === BODY_TYPE_BINARY) { + return typeof body.filePath === "string" ? { filePath: body.filePath } : {}; + } + + return toTextBody(body, fromBodyType, toBodyType); +} + +export function normalizeGraphQLBody(body: Body): GraphQLBody { + return toGraphQLBody(body); +} + +function toGraphQLBody(body: Body): GraphQLBody { + if (typeof body.query === "string") { + return { + query: body.query, + variables: typeof body.variables === "string" ? body.variables : undefined, + }; + } + + if (typeof body.text === "string") { + const parsed = parseJsonObject(body.text); + if (parsed != null) { + const query = typeof parsed.query === "string" ? parsed.query : ""; + const variables = + parsed.variables == null ? undefined : JSON.stringify(parsed.variables, null, 2); + + return { query, variables }; + } + + return { query: body.text, variables: undefined }; + } + + return { query: "", variables: undefined }; +} + +function toFormBody(body: Body): Body { + if (Array.isArray(body.form)) { + return { + form: body.form.map((p) => ({ + enabled: p.enabled !== false, + name: typeof p.name === "string" ? p.name : "", + value: stringifyFormValue(p.value ?? p.file), + contentType: typeof p.contentType === "string" ? p.contentType : undefined, + filename: typeof p.filename === "string" ? p.filename : undefined, + file: typeof p.file === "string" ? p.file : undefined, + id: typeof p.id === "string" ? p.id : undefined, + })), + }; + } + + if (typeof body.text === "string") { + const parsed = parseJsonObject(body.text); + if (parsed != null) { + return { form: objectToFormPairs(parsed) }; + } + + return { form: urlEncodedTextToFormPairs(body.text) }; + } + + if (typeof body.query === "string") { + return { + form: [ + { enabled: true, name: "query", value: body.query }, + ...(typeof body.variables === "string" + ? [{ enabled: true, name: "variables", value: body.variables }] + : []), + ], + }; + } + + return { form: [] }; +} + +function toTextBody(body: Body, fromBodyType: BodyType, toBodyType: BodyType): Body { + const sendJsonComments = + typeof body.sendJsonComments === "boolean" ? { sendJsonComments: body.sendJsonComments } : {}; + + if (typeof body.text === "string") { + return { text: body.text, ...sendJsonComments }; + } + + if (Array.isArray(body.form)) { + if (toBodyType === BODY_TYPE_JSON) { + return { text: JSON.stringify(formBodyToObject(body.form), null, 2) }; + } + + return { text: formBodyToUrlEncodedText(body.form) }; + } + + if (typeof body.query === "string") { + if (toBodyType === BODY_TYPE_JSON || fromBodyType === BODY_TYPE_GRAPHQL) { + const value: Record = { query: body.query }; + if (typeof body.variables === "string" && body.variables.trim() !== "") { + value.variables = parseJson(body.variables) ?? body.variables; + } + + return { text: JSON.stringify(value, null, 2) }; + } + + return { text: body.query }; + } + + if (typeof body.filePath === "string") { + return { text: body.filePath }; + } + + return { text: "" }; +} + +function formBodyToUrlEncodedText(form: unknown[]): string { + const params = new URLSearchParams(); + + for (const pair of form) { + if (!isRecord(pair)) continue; + if (pair.enabled === false) continue; + if (typeof pair.name !== "string" || pair.name === "") continue; + params.append(pair.name, stringifyFormValue(pair.value)); + } + + return params.toString(); +} + +function urlEncodedTextToFormPairs(text: string) { + if (!text.includes("=") && !text.includes("&")) { + return []; + } + + return Array.from(new URLSearchParams(text).entries()).map(([name, value]) => ({ + enabled: true, + name, + value, + })); +} + +function formBodyToObject(form: unknown[]) { + const result: Record = {}; + + for (const pair of form) { + if (!isRecord(pair)) continue; + if (pair.enabled === false) continue; + if (typeof pair.name !== "string" || pair.name === "") continue; + + const value = stringifyFormValue(pair.value); + if (pair.name in result) { + const existing = result[pair.name]; + result[pair.name] = Array.isArray(existing) ? [...existing, value] : [existing, value]; + } else { + result[pair.name] = value; + } + } + + return result; +} + +function objectToFormPairs(value: Record) { + return Object.entries(value).flatMap(([name, rawValue]) => { + if (Array.isArray(rawValue)) { + return rawValue.map((item) => ({ enabled: true, name, value: stringifyFormValue(item) })); + } + + return [{ enabled: true, name, value: stringifyFormValue(rawValue) }]; + }); +} + +function stringifyFormValue(value: unknown): string { + if (value == null) return ""; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +function parseJsonObject(text: string): Record | null { + const parsed = parseJson(text); + return isRecord(parsed) ? parsed : null; +} + +function parseJson(text: string): unknown | null { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +}