Compare commits

...

1 Commits

Author SHA1 Message Date
Gregory Schier e7a0a6077e Convert request bodies when changing type 2026-07-04 18:51:05 -07:00
4 changed files with 338 additions and 20 deletions
@@ -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<HttpRequest> = { bodyType };
const patch: Partial<HttpRequest> = {
bodyType,
body: convertRequestBody({
body: activeRequest.body,
fromBodyType: activeRequest.bodyType,
toBodyType: bodyType,
}),
};
let newContentType: string | null | undefined;
if (bodyType === BODY_TYPE_NONE) {
newContentType = null;
@@ -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<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
@@ -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
</div>
);
}
function tryParseJson(text: string, fallback: unknown) {
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
@@ -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" },
],
});
});
});
@@ -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<string, unknown> = { 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<string, unknown> = {};
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<string, unknown>) {
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<string, unknown> | 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<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}