mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-06 21:05:24 +02:00
Convert request bodies when changing type (#499)
This commit is contained in:
@@ -20,6 +20,7 @@ import { deepEqualAtom } from "../lib/atoms";
|
|||||||
import { languageFromContentType } from "../lib/contentType";
|
import { languageFromContentType } from "../lib/contentType";
|
||||||
import { generateId } from "../lib/generateId";
|
import { generateId } from "../lib/generateId";
|
||||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
||||||
|
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||||
import {
|
import {
|
||||||
BODY_TYPE_BINARY,
|
BODY_TYPE_BINARY,
|
||||||
BODY_TYPE_FORM_MULTIPART,
|
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;
|
let newContentType: string | null | undefined;
|
||||||
if (bodyType === BODY_TYPE_NONE) {
|
if (bodyType === BODY_TYPE_NONE) {
|
||||||
newContentType = null;
|
newContentType = null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
|
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
|
||||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||||
@@ -11,9 +11,13 @@ import type { DropdownItem } from "../core/Dropdown";
|
|||||||
import { Dropdown } from "../core/Dropdown";
|
import { Dropdown } from "../core/Dropdown";
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
import type { EditorProps } from "../core/Editor/Editor";
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
import { Editor } from "../core/Editor/LazyEditor";
|
||||||
|
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
||||||
|
import { RadioDropdown } from "../core/RadioDropdown";
|
||||||
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
||||||
import { Separator } from "../core/Separator";
|
import { Separator } from "../core/Separator";
|
||||||
import { tryFormatGraphql } from "../../lib/formatters";
|
import { tryFormatGraphql } from "../../lib/formatters";
|
||||||
|
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
||||||
|
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
||||||
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
||||||
|
|
||||||
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
||||||
@@ -22,6 +26,8 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
|
|||||||
request: HttpRequest;
|
request: HttpRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OPERATION_NAME_NOT_SPECIFIED = "";
|
||||||
|
|
||||||
export function GraphQLEditor(props: Props) {
|
export function GraphQLEditor(props: Props) {
|
||||||
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
||||||
// key on the request ID as a workaround for now.
|
// key on the request ID as a workaround for now.
|
||||||
@@ -38,25 +44,25 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
||||||
query: string;
|
query: string;
|
||||||
variables: string | undefined;
|
variables: string | undefined;
|
||||||
|
operationName?: string;
|
||||||
}>(() => {
|
}>(() => {
|
||||||
// Migrate text bodies to GraphQL format
|
// Migrate text bodies to GraphQL format
|
||||||
// NOTE: This is how GraphQL used to be stored
|
// NOTE: This is how GraphQL used to be stored
|
||||||
if ("text" in request.body) {
|
return normalizeGraphQLBody(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]);
|
}, [extraEditorProps.forceUpdateKey]);
|
||||||
|
|
||||||
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
|
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
|
||||||
const isDocOpen = isDocOpenRecord[request.id] !== undefined;
|
const isDocOpen = isDocOpenRecord[request.id] !== undefined;
|
||||||
|
const parsedOperationNames = useMemo(
|
||||||
|
() => parseGraphQLOperationNames(currentBody.query),
|
||||||
|
[currentBody.query],
|
||||||
|
);
|
||||||
|
const operationNames = useMemo(() => parsedOperationNames ?? [], [parsedOperationNames]);
|
||||||
|
|
||||||
const handleChangeQuery = useCallback(
|
const handleChangeQuery = useCallback(
|
||||||
(query: string) => {
|
(query: string) => {
|
||||||
setCurrentBody(({ variables }) => {
|
setCurrentBody(({ variables, operationName }) => {
|
||||||
const newBody = { query, variables };
|
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||||
onChange(newBody);
|
onChange(newBody);
|
||||||
return newBody;
|
return newBody;
|
||||||
});
|
});
|
||||||
@@ -66,8 +72,8 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
|
|
||||||
const handleChangeVariables = useCallback(
|
const handleChangeVariables = useCallback(
|
||||||
(variables: string) => {
|
(variables: string) => {
|
||||||
setCurrentBody(({ query }) => {
|
setCurrentBody(({ query, operationName }) => {
|
||||||
const newBody = { query, variables: variables || undefined };
|
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||||
onChange(newBody);
|
onChange(newBody);
|
||||||
return newBody;
|
return newBody;
|
||||||
});
|
});
|
||||||
@@ -75,125 +81,196 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
[onChange, setCurrentBody],
|
[onChange, setCurrentBody],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleChangeOperationName = useCallback(
|
||||||
|
(operationName: string) => {
|
||||||
|
setCurrentBody(({ query, variables }) => {
|
||||||
|
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||||
|
onChange(newBody);
|
||||||
|
return newBody;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onChange, setCurrentBody],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parsedOperationNames == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBody.operationName && operationNames.includes(currentBody.operationName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the saved body aligned with the visible default, so send/copy use the selected operation.
|
||||||
|
const operationName = operationNames[0];
|
||||||
|
if (currentBody.operationName === operationName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentBody(({ query, variables }) => {
|
||||||
|
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||||
|
onChange(newBody);
|
||||||
|
return newBody;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
currentBody.operationName,
|
||||||
|
onChange,
|
||||||
|
operationNames,
|
||||||
|
parsedOperationNames,
|
||||||
|
setCurrentBody,
|
||||||
|
]);
|
||||||
|
|
||||||
const actions = useMemo<EditorProps["actions"]>(
|
const actions = useMemo<EditorProps["actions"]>(
|
||||||
() => [
|
() => [
|
||||||
<div key="actions" className="flex flex-row opacity-100! shadow!">
|
operationNames.length > 0 ? (
|
||||||
<div key="introspection" className="opacity-100!">
|
<div key="operation" className="opacity-100!">
|
||||||
{schema === undefined ? null /* Initializing */ : (
|
<RadioDropdown
|
||||||
<Dropdown
|
value={currentBody.operationName ?? operationNames[0] ?? OPERATION_NAME_NOT_SPECIFIED}
|
||||||
items={[
|
onChange={handleChangeOperationName}
|
||||||
...((schema != null
|
items={[
|
||||||
? [
|
{ type: "separator", label: "Operation Name" },
|
||||||
{
|
{
|
||||||
label: "Clear",
|
label: <span className="text-text-subtle italic">Not specified</span>,
|
||||||
onSelect: clear,
|
value: OPERATION_NAME_NOT_SPECIFIED,
|
||||||
color: "danger",
|
},
|
||||||
leftSlot: <Icon icon="trash" />,
|
...operationNames.map((operationName) => ({
|
||||||
},
|
label: operationName,
|
||||||
{ type: "separator" },
|
value: operationName,
|
||||||
]
|
})),
|
||||||
: []) satisfies DropdownItem[]),
|
] satisfies RadioDropdownItem<string>[]}
|
||||||
{
|
>
|
||||||
hidden: !error,
|
<Button size="sm" variant="border" title="Select Operation" forDropdown>
|
||||||
label: (
|
{currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED ? (
|
||||||
<Banner color="danger">
|
<span className="text-text-subtle italic">Not specified</span>
|
||||||
<p className="mb-1">Schema introspection failed</p>
|
) : (
|
||||||
<Button
|
currentBody.operationName ?? operationNames[0]
|
||||||
size="xs"
|
)}
|
||||||
color="danger"
|
</Button>
|
||||||
variant="border"
|
</RadioDropdown>
|
||||||
onClick={() => {
|
|
||||||
showDialog({
|
|
||||||
title: "Introspection Failed",
|
|
||||||
size: "sm",
|
|
||||||
id: "introspection-failed",
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<>
|
|
||||||
<FormattedError>{error ?? "unknown"}</FormattedError>
|
|
||||||
<div className="w-full my-4">
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
hide();
|
|
||||||
await refetch();
|
|
||||||
}}
|
|
||||||
className="ml-auto"
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Retry Request
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View Error
|
|
||||||
</Button>
|
|
||||||
</Banner>
|
|
||||||
),
|
|
||||||
type: "content",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hidden: schema == null,
|
|
||||||
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
|
||||||
leftSlot: <Icon icon="book_open_text" />,
|
|
||||||
onSelect: () => {
|
|
||||||
setGraphqlDocStateAtomValue((v) => ({
|
|
||||||
...v,
|
|
||||||
[request.id]: isDocOpen ? undefined : null,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Introspect Schema",
|
|
||||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: refetch,
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "Setting" },
|
|
||||||
{
|
|
||||||
label: "Automatic Introspection",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: () => {
|
|
||||||
setAutoIntrospectDisabled({
|
|
||||||
...autoIntrospectDisabled,
|
|
||||||
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
leftSlot: (
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
autoIntrospectDisabled?.[baseRequest.id]
|
|
||||||
? "check_square_unchecked"
|
|
||||||
: "check_square_checked"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
title="Refetch Schema"
|
|
||||||
isLoading={isLoading}
|
|
||||||
color={error ? "danger" : "default"}
|
|
||||||
forDropdown
|
|
||||||
>
|
|
||||||
{error ? "Introspection Failed" : schema ? "Schema" : "No Schema"}
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null,
|
||||||
|
<div key="introspection" className="opacity-100!">
|
||||||
|
{schema === undefined ? null /* Initializing */ : (
|
||||||
|
<Dropdown
|
||||||
|
items={[
|
||||||
|
...((schema != null
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Clear",
|
||||||
|
onSelect: clear,
|
||||||
|
color: "danger",
|
||||||
|
leftSlot: <Icon icon="trash" />,
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
]
|
||||||
|
: []) satisfies DropdownItem[]),
|
||||||
|
{
|
||||||
|
hidden: !error,
|
||||||
|
label: (
|
||||||
|
<Banner color="danger">
|
||||||
|
<p className="mb-1">Schema introspection failed</p>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="danger"
|
||||||
|
variant="border"
|
||||||
|
onClick={() => {
|
||||||
|
showDialog({
|
||||||
|
title: "Introspection Failed",
|
||||||
|
size: "sm",
|
||||||
|
id: "introspection-failed",
|
||||||
|
render: ({ hide }) => (
|
||||||
|
<>
|
||||||
|
<FormattedError>{error ?? "unknown"}</FormattedError>
|
||||||
|
<div className="w-full my-4">
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
hide();
|
||||||
|
await refetch();
|
||||||
|
}}
|
||||||
|
className="ml-auto"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Retry Request
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View Error
|
||||||
|
</Button>
|
||||||
|
</Banner>
|
||||||
|
),
|
||||||
|
type: "content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hidden: schema == null,
|
||||||
|
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
||||||
|
leftSlot: <Icon icon="book_open_text" />,
|
||||||
|
onSelect: () => {
|
||||||
|
setGraphqlDocStateAtomValue((v) => ({
|
||||||
|
...v,
|
||||||
|
[request.id]: isDocOpen ? undefined : null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Introspect Schema",
|
||||||
|
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: refetch,
|
||||||
|
},
|
||||||
|
{ type: "separator", label: "Setting" },
|
||||||
|
{
|
||||||
|
label: "Automatic Introspection",
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: () => {
|
||||||
|
setAutoIntrospectDisabled({
|
||||||
|
...autoIntrospectDisabled,
|
||||||
|
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
leftSlot: (
|
||||||
|
<Icon
|
||||||
|
icon={
|
||||||
|
autoIntrospectDisabled?.[baseRequest.id]
|
||||||
|
? "check_square_unchecked"
|
||||||
|
: "check_square_checked"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="border"
|
||||||
|
title="Refetch Schema"
|
||||||
|
isLoading={isLoading}
|
||||||
|
color={error ? "danger" : "default"}
|
||||||
|
forDropdown
|
||||||
|
>
|
||||||
|
{error ? "Introspection Failed" : schema ? "Schema" : "No Schema"}
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
schema,
|
schema,
|
||||||
clear,
|
clear,
|
||||||
error,
|
error,
|
||||||
|
currentBody.operationName,
|
||||||
|
handleChangeOperationName,
|
||||||
isDocOpen,
|
isDocOpen,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
operationNames,
|
||||||
refetch,
|
refetch,
|
||||||
autoIntrospectDisabled,
|
autoIntrospectDisabled,
|
||||||
baseRequest.id,
|
baseRequest.id,
|
||||||
@@ -237,10 +314,23 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryParseJson(text: string, fallback: unknown) {
|
function buildGraphQLBody(body: {
|
||||||
try {
|
query: string;
|
||||||
return JSON.parse(text);
|
variables: string | undefined;
|
||||||
} catch {
|
operationName?: string;
|
||||||
return fallback;
|
}) {
|
||||||
|
const result: {
|
||||||
|
query: string;
|
||||||
|
variables: string | undefined;
|
||||||
|
operationName?: string;
|
||||||
|
} = {
|
||||||
|
query: body.query,
|
||||||
|
variables: body.variables || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof body.operationName === "string") {
|
||||||
|
result.operationName = body.operationName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { getGraphQLOperationNames, parseGraphQLOperationNames } from "./graphqlOperationNames";
|
||||||
|
|
||||||
|
describe("getGraphQLOperationNames", () => {
|
||||||
|
test("returns named operations from a GraphQL document", () => {
|
||||||
|
expect(
|
||||||
|
getGraphQLOperationNames(`
|
||||||
|
query GetUser { user { id } }
|
||||||
|
mutation UpdateUser { updateUser { id } }
|
||||||
|
subscription UserChanged { userChanged { id } }
|
||||||
|
fragment UserFields on User { id }
|
||||||
|
`),
|
||||||
|
).toEqual(["GetUser", "UpdateUser", "UserChanged"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores anonymous operations", () => {
|
||||||
|
expect(getGraphQLOperationNames(`{ user { id } }`)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns unique operation names in document order", () => {
|
||||||
|
expect(
|
||||||
|
getGraphQLOperationNames(`
|
||||||
|
query GetUser { user { id } }
|
||||||
|
query GetUser { user { name } }
|
||||||
|
query ListUsers { users { id } }
|
||||||
|
`),
|
||||||
|
).toEqual(["GetUser", "ListUsers"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns no operations for invalid in-progress documents", () => {
|
||||||
|
expect(getGraphQLOperationNames(`query GetUser { user {`)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when parsing invalid in-progress documents", () => {
|
||||||
|
expect(parseGraphQLOperationNames(`query GetUser { user {`)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Kind, parse } from "graphql";
|
||||||
|
|
||||||
|
export function getGraphQLOperationNames(query: string): string[] {
|
||||||
|
return parseGraphQLOperationNames(query) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGraphQLOperationNames(query: string): string[] | null {
|
||||||
|
try {
|
||||||
|
const names: string[] = [];
|
||||||
|
|
||||||
|
for (const definition of parse(query).definitions) {
|
||||||
|
if (definition.kind !== Kind.OPERATION_DEFINITION || definition.name == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = definition.name.value;
|
||||||
|
if (!names.includes(name)) {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import {
|
||||||
|
BODY_TYPE_BINARY,
|
||||||
|
BODY_TYPE_FORM_URLENCODED,
|
||||||
|
BODY_TYPE_GRAPHQL,
|
||||||
|
BODY_TYPE_JSON,
|
||||||
|
BODY_TYPE_NONE,
|
||||||
|
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" },
|
||||||
|
operationName: "GetUser",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(body).toEqual({
|
||||||
|
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||||
|
variables: '{\n "id": "123"\n}',
|
||||||
|
operationName: "GetUser",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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" }',
|
||||||
|
operationName: "GetUser",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(body).toEqual({
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||||
|
variables: { id: "123" },
|
||||||
|
operationName: "GetUser",
|
||||||
|
},
|
||||||
|
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("preserves text when converting to form bodies cannot build form pairs", () => {
|
||||||
|
const body = convertRequestBody({
|
||||||
|
fromBodyType: BODY_TYPE_XML,
|
||||||
|
toBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||||
|
body: { text: "a=1&b=two+words" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(body).toEqual({
|
||||||
|
text: "a=1&b=two+words",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves JSON text that is not a GraphQL envelope", () => {
|
||||||
|
const body = convertRequestBody({
|
||||||
|
fromBodyType: BODY_TYPE_JSON,
|
||||||
|
toBodyType: BODY_TYPE_GRAPHQL,
|
||||||
|
body: { text: JSON.stringify({ name: "Yaak" }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(body).toEqual({
|
||||||
|
text: JSON.stringify({ name: "Yaak" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves JSON arrays and primitives when converting to GraphQL", () => {
|
||||||
|
for (const text of [JSON.stringify([1, 2, 3]), JSON.stringify("query"), "123", "null"]) {
|
||||||
|
const body = convertRequestBody({
|
||||||
|
fromBodyType: BODY_TYPE_JSON,
|
||||||
|
toBodyType: BODY_TYPE_GRAPHQL,
|
||||||
|
body: { text },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(body).toEqual({ text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves text when converting to binary cannot build a file body", () => {
|
||||||
|
const body = convertRequestBody({
|
||||||
|
fromBodyType: BODY_TYPE_JSON,
|
||||||
|
toBodyType: BODY_TYPE_BINARY,
|
||||||
|
body: { text: '{ "name": "Yaak" }' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(body).toEqual({
|
||||||
|
text: '{ "name": "Yaak" }',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears body when converting to no body", () => {
|
||||||
|
const body = convertRequestBody({
|
||||||
|
fromBodyType: BODY_TYPE_JSON,
|
||||||
|
toBodyType: BODY_TYPE_NONE,
|
||||||
|
body: { text: '{ "name": "Yaak" }' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(body).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
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;
|
||||||
|
operationName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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) ?? body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toBodyType === BODY_TYPE_FORM_URLENCODED || toBodyType === BODY_TYPE_FORM_MULTIPART) {
|
||||||
|
return toFormBody(body) ?? body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toBodyType === BODY_TYPE_BINARY) {
|
||||||
|
return typeof body.filePath === "string" ? { filePath: body.filePath } : body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toTextBody(body, fromBodyType, toBodyType) ?? body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeGraphQLBody(body: Body): GraphQLBody {
|
||||||
|
return toGraphQLBody(body) ?? { query: "", variables: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGraphQLBody(body: Body): GraphQLBody | null {
|
||||||
|
if (typeof body.query === "string") {
|
||||||
|
const result: GraphQLBody = {
|
||||||
|
query: body.query,
|
||||||
|
variables: typeof body.variables === "string" ? body.variables : undefined,
|
||||||
|
};
|
||||||
|
if (typeof body.operationName === "string") {
|
||||||
|
result.operationName = body.operationName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.text === "string") {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(body.text);
|
||||||
|
if (!isRecord(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed.query !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = parsed.query;
|
||||||
|
const variables =
|
||||||
|
parsed.variables == null ? undefined : JSON.stringify(parsed.variables, null, 2);
|
||||||
|
|
||||||
|
const result: GraphQLBody = { query, variables };
|
||||||
|
if (typeof parsed.operationName === "string") {
|
||||||
|
result.operationName = parsed.operationName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return { query: body.text, variables: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormBody(body: Body): Body | null {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTextBody(body: Body, fromBodyType: BodyType, toBodyType: BodyType): Body | null {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (typeof body.operationName === "string" && body.operationName.trim() !== "") {
|
||||||
|
value.operationName = body.operationName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: JSON.stringify(value, null, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: body.query };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.filePath === "string") {
|
||||||
|
return { text: body.filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 stringifyFormValue(value: unknown): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -191,12 +191,16 @@ fn build_url(r: &HttpRequest) -> String {
|
|||||||
fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Value>) -> String {
|
fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Value>) -> String {
|
||||||
let query = get_str_map(body, "query").to_string();
|
let query = get_str_map(body, "query").to_string();
|
||||||
let variables = strip_json_comments(&get_str_map(body, "variables"));
|
let variables = strip_json_comments(&get_str_map(body, "variables"));
|
||||||
|
let operation_name = get_str_map(body, "operationName").to_string();
|
||||||
let mut params = vec![("query".to_string(), query)];
|
let mut params = vec![("query".to_string(), query)];
|
||||||
if !variables.trim().is_empty() {
|
if !variables.trim().is_empty() {
|
||||||
params.push(("variables".to_string(), variables));
|
params.push(("variables".to_string(), variables));
|
||||||
}
|
}
|
||||||
|
if !operation_name.trim().is_empty() {
|
||||||
|
params.push(("operationName".to_string(), operation_name));
|
||||||
|
}
|
||||||
// Strip existing query/variables params to avoid duplicates
|
// Strip existing query/variables params to avoid duplicates
|
||||||
let url = strip_query_params(url, &["query", "variables"]);
|
let url = strip_query_params(url, &["query", "variables", "operationName"]);
|
||||||
append_query_params(&url, params)
|
append_query_params(&url, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,23 +333,30 @@ fn build_graphql_body(
|
|||||||
) -> Option<SendableBodyWithMeta> {
|
) -> Option<SendableBodyWithMeta> {
|
||||||
let query = get_str_map(body, "query");
|
let query = get_str_map(body, "query");
|
||||||
let variables = strip_json_comments(&get_str_map(body, "variables"));
|
let variables = strip_json_comments(&get_str_map(body, "variables"));
|
||||||
|
let operation_name = get_str_map(body, "operationName");
|
||||||
|
|
||||||
if method.to_lowercase() == "get" {
|
if method.to_lowercase() == "get" {
|
||||||
// GraphQL GET requests use query parameters, not a body
|
// GraphQL GET requests use query parameters, not a body
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = if variables.trim().is_empty() {
|
let mut body = serde_json::Map::new();
|
||||||
format!(r#"{{"query":{}}}"#, serde_json::to_string(&query).unwrap_or_default())
|
body.insert("query".to_string(), serde_json::Value::String(query.to_string()));
|
||||||
} else {
|
if !variables.trim().is_empty() {
|
||||||
format!(
|
body.insert(
|
||||||
r#"{{"query":{},"variables":{}}}"#,
|
"variables".to_string(),
|
||||||
serde_json::to_string(&query).unwrap_or_default(),
|
serde_json::from_str(&variables)
|
||||||
variables
|
.unwrap_or_else(|_| serde_json::Value::String(variables)),
|
||||||
)
|
);
|
||||||
};
|
}
|
||||||
|
if !operation_name.trim().is_empty() {
|
||||||
|
body.insert(
|
||||||
|
"operationName".to_string(),
|
||||||
|
serde_json::Value::String(operation_name.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Some(SendableBodyWithMeta::Bytes(Bytes::from(body)))
|
Some(SendableBodyWithMeta::Bytes(Bytes::from(serde_json::to_string(&body).unwrap_or_default())))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_multipart_body(
|
async fn build_multipart_body(
|
||||||
@@ -522,6 +533,33 @@ mod tests {
|
|||||||
assert_eq!(result, "https://example.com/api?foo=bar&baz=qux");
|
assert_eq!(result, "https://example.com/api?foo=bar&baz=qux");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_replaces_graphql_operation_name_from_body() {
|
||||||
|
let mut body = BTreeMap::new();
|
||||||
|
body.insert("query".to_string(), json!("query Foo { foo } query Bar { bar }"));
|
||||||
|
body.insert("operationName".to_string(), json!("Bar"));
|
||||||
|
|
||||||
|
let r = HttpRequest {
|
||||||
|
method: "GET".to_string(),
|
||||||
|
body_type: Some("graphql".to_string()),
|
||||||
|
body,
|
||||||
|
url: "https://example.com/graphql".to_string(),
|
||||||
|
url_parameters: vec![HttpUrlParameter {
|
||||||
|
enabled: true,
|
||||||
|
name: "operationName".to_string(),
|
||||||
|
value: "Foo".to_string(),
|
||||||
|
id: None,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = build_url(&r);
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
"https://example.com/graphql?query=query%20Foo%20%7B%20foo%20%7D%20query%20Bar%20%7B%20bar%20%7D&operationName=Bar",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_url_with_disabled_params() {
|
fn test_build_url_with_disabled_params() {
|
||||||
let r = HttpRequest {
|
let r = HttpRequest {
|
||||||
@@ -880,9 +918,34 @@ mod tests {
|
|||||||
let result = build_graphql_body("POST", &body);
|
let result = build_graphql_body("POST", &body);
|
||||||
match result {
|
match result {
|
||||||
Some(SendableBodyWithMeta::Bytes(bytes)) => {
|
Some(SendableBodyWithMeta::Bytes(bytes)) => {
|
||||||
let expected =
|
assert_eq!(
|
||||||
r#"{"query":"{ user(id: $id) { name } }","variables":{"id": "123"}}"#;
|
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
|
||||||
assert_eq!(bytes, Bytes::from(expected));
|
json!({
|
||||||
|
"query": "{ user(id: $id) { name } }",
|
||||||
|
"variables": { "id": "123" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Some(SendableBody::Bytes)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_graphql_body_with_operation_name() {
|
||||||
|
let mut body = BTreeMap::new();
|
||||||
|
body.insert("query".to_string(), json!("query Search { viewer { id } }"));
|
||||||
|
body.insert("operationName".to_string(), json!("Search"));
|
||||||
|
|
||||||
|
let result = build_graphql_body("POST", &body);
|
||||||
|
match result {
|
||||||
|
Some(SendableBodyWithMeta::Bytes(bytes)) => {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
|
||||||
|
json!({
|
||||||
|
"query": "query Search { viewer { id } }",
|
||||||
|
"operationName": "Search",
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_ => panic!("Expected Some(SendableBody::Bytes)"),
|
_ => panic!("Expected Some(SendableBody::Bytes)"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
|
|||||||
const body = {
|
const body = {
|
||||||
query: request.body.query || "",
|
query: request.body.query || "",
|
||||||
variables: maybeParseJSON(request.body.variables, undefined),
|
variables: maybeParseJSON(request.body.variables, undefined),
|
||||||
|
operationName: request.body.operationName || undefined,
|
||||||
};
|
};
|
||||||
xs.push("--data", quote(JSON.stringify(body)));
|
xs.push("--data", quote(JSON.stringify(body)));
|
||||||
xs.push(NEWLINE);
|
xs.push(NEWLINE);
|
||||||
|
|||||||
@@ -66,6 +66,25 @@ describe("exporter-curl", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Exports POST with GraphQL operation name", async () => {
|
||||||
|
expect(
|
||||||
|
await convertToCurl({
|
||||||
|
url: "https://yaak.app",
|
||||||
|
method: "POST",
|
||||||
|
bodyType: "graphql",
|
||||||
|
body: {
|
||||||
|
query: "query Foo { foo } query Bar { bar }",
|
||||||
|
operationName: "Foo",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
[
|
||||||
|
`curl -X POST 'https://yaak.app'`,
|
||||||
|
`--data '{"query":"query Foo { foo } query Bar { bar }","operationName":"Foo"}'`,
|
||||||
|
].join(" \\\n "),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("Exports POST with GraphQL data no variables", async () => {
|
test("Exports POST with GraphQL data no variables", async () => {
|
||||||
expect(
|
expect(
|
||||||
await convertToCurl({
|
await convertToCurl({
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
type GraphQLDetectionSignal = {
|
||||||
|
score: number;
|
||||||
|
requiresGraphQLDocument?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GraphQLJsonBody = {
|
||||||
|
query: string;
|
||||||
|
variables?: string;
|
||||||
|
operationName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GraphQLJsonBodyArgs = {
|
||||||
|
mimeType: string | null;
|
||||||
|
text: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isGraphQLJsonBody(args: GraphQLJsonBodyArgs): boolean {
|
||||||
|
return parseGraphQLJsonBody(args) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGraphQLJsonBody({
|
||||||
|
mimeType,
|
||||||
|
text,
|
||||||
|
url,
|
||||||
|
}: GraphQLJsonBodyArgs): GraphQLJsonBody | null {
|
||||||
|
if (mimeType !== "application/json") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsed as Record<string, unknown>;
|
||||||
|
if (typeof body.query !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasExtraGraphQLEnvelopeFields(body)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signals = getGraphQLDetectionSignals(body, url);
|
||||||
|
const score = signals.reduce((total, signal) => total + signal.score, 0);
|
||||||
|
const hasGraphQLDocument = signals.some((signal) => signal.requiresGraphQLDocument);
|
||||||
|
if (!hasGraphQLDocument || score < 4) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: GraphQLJsonBody = { query: body.query };
|
||||||
|
if (body.variables != null) {
|
||||||
|
result.variables =
|
||||||
|
typeof body.variables === "string" ? body.variables : JSON.stringify(body.variables, null, 2);
|
||||||
|
}
|
||||||
|
if (typeof body.operationName === "string") {
|
||||||
|
result.operationName = body.operationName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExtraGraphQLEnvelopeFields(body: Record<string, unknown>): boolean {
|
||||||
|
const allowedKeys = new Set(["query", "variables", "operationName"]);
|
||||||
|
return Object.keys(body).some((key) => !allowedKeys.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGraphQLDetectionSignals(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
url: string,
|
||||||
|
): GraphQLDetectionSignal[] {
|
||||||
|
const signals: GraphQLDetectionSignal[] = [];
|
||||||
|
const query = body.query as string;
|
||||||
|
const urlPath = getUrlPath(url).toLowerCase();
|
||||||
|
|
||||||
|
if (/\b(graphql|gql)\b/.test(urlPath)) {
|
||||||
|
signals.push({ score: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(query|mutation|subscription|fragment)\b/.test(query.trim())) {
|
||||||
|
signals.push({ score: 3 });
|
||||||
|
} else if (/^\{[\s\S]*\}$/.test(query.trim())) {
|
||||||
|
signals.push({ score: 3, requiresGraphQLDocument: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\{[\s\S]*\}/.test(query)) {
|
||||||
|
signals.push({ score: 1, requiresGraphQLDocument: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.operationName === "string" && body.operationName.trim() !== "") {
|
||||||
|
signals.push({ score: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
body.variables != null &&
|
||||||
|
(typeof body.variables === "object" || typeof body.variables === "string")
|
||||||
|
) {
|
||||||
|
signals.push({ score: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return signals;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrlPath(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).pathname;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
Workspace,
|
Workspace,
|
||||||
} from "@yaakapp/api";
|
} from "@yaakapp/api";
|
||||||
import { split } from "shlex";
|
import { split } from "shlex";
|
||||||
|
import { parseGraphQLJsonBody } from "./graphql";
|
||||||
|
|
||||||
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||||
|
|
||||||
@@ -464,6 +465,8 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
|||||||
let body = {};
|
let body = {};
|
||||||
let bodyType: string | null = null;
|
let bodyType: string | null = null;
|
||||||
const bodyAsGET = getPairValue(flagsByName, false, ["G", "get"]);
|
const bodyAsGET = getPairValue(flagsByName, false, ["G", "get"]);
|
||||||
|
const hasDataBody = dataParameters.length > 0 && !bodyAsGET;
|
||||||
|
const hasFormBody = multipartFormDataFromRaw != null || formDataParams.length > 0;
|
||||||
|
|
||||||
if (multipartFormDataFromRaw) {
|
if (multipartFormDataFromRaw) {
|
||||||
// Handle multipart form data parsed from --data-raw (Chrome DevTools format)
|
// Handle multipart form data parsed from --data-raw (Chrome DevTools format)
|
||||||
@@ -491,15 +494,21 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
} else if (dataParameters.length > 0) {
|
} else if (dataParameters.length > 0) {
|
||||||
bodyType =
|
const text = dataParameters
|
||||||
mimeType === "application/json" || mimeType === "text/xml" || mimeType === "text/plain"
|
.map(({ name, value }) => (name && value ? `${name}=${value}` : name || value))
|
||||||
? mimeType
|
.join("&");
|
||||||
: "other";
|
const graphqlBody = parseGraphQLJsonBody({ mimeType, text, url });
|
||||||
body = {
|
|
||||||
text: dataParameters
|
if (graphqlBody != null) {
|
||||||
.map(({ name, value }) => (name && value ? `${name}=${value}` : name || value))
|
bodyType = "graphql";
|
||||||
.join("&"),
|
body = graphqlBody;
|
||||||
};
|
} else if (mimeType === "application/json" || mimeType === "text/xml" || mimeType === "text/plain") {
|
||||||
|
bodyType = mimeType;
|
||||||
|
body = { text };
|
||||||
|
} else {
|
||||||
|
bodyType = "other";
|
||||||
|
body = { text };
|
||||||
|
}
|
||||||
} else if (formDataParams.length) {
|
} else if (formDataParams.length) {
|
||||||
bodyType = mimeType ?? "multipart/form-data";
|
bodyType = mimeType ?? "multipart/form-data";
|
||||||
body = {
|
body = {
|
||||||
@@ -517,8 +526,8 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
|||||||
// Method
|
// Method
|
||||||
let method = getPairValue(flagsByName, "", ["X", "request"]).toUpperCase();
|
let method = getPairValue(flagsByName, "", ["X", "request"]).toUpperCase();
|
||||||
|
|
||||||
if (method === "" && body) {
|
if (method === "") {
|
||||||
method = "text" in body || "form" in body ? "POST" : "GET";
|
method = hasDataBody || hasFormBody ? "POST" : "GET";
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: ExportResources["httpRequests"][0] = {
|
const request: ExportResources["httpRequests"][0] = {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { isGraphQLJsonBody, parseGraphQLJsonBody } from "../src/graphql";
|
||||||
|
|
||||||
|
describe("isGraphQLJsonBody", () => {
|
||||||
|
test("detects named query documents without a GraphQL URL", () => {
|
||||||
|
const args = {
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify({
|
||||||
|
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||||
|
variables: { id: "123" },
|
||||||
|
operationName: "Search",
|
||||||
|
}),
|
||||||
|
url: "https://api.example.com/search",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isGraphQLJsonBody(args)).toBe(true);
|
||||||
|
expect(parseGraphQLJsonBody(args)).toEqual({
|
||||||
|
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||||
|
variables: '{\n "id": "123"\n}',
|
||||||
|
operationName: "Search",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects mutation documents", () => {
|
||||||
|
expect(
|
||||||
|
isGraphQLJsonBody({
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify({ query: "mutation Save { saveThing { id } }" }),
|
||||||
|
url: "https://api.example.com",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects anonymous selection set documents", () => {
|
||||||
|
expect(
|
||||||
|
isGraphQLJsonBody({
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify({ query: "{ viewer { id email } }" }),
|
||||||
|
url: "https://api.example.com",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects document bodies on GraphQL-looking paths", () => {
|
||||||
|
expect(
|
||||||
|
isGraphQLJsonBody({
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify({ query: "query Search { viewer { id } }", operationName: "Search" }),
|
||||||
|
url: "https://api.example.com/v1/graphql",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not detect incomplete operation documents even on GraphQL-looking paths", () => {
|
||||||
|
expect(
|
||||||
|
isGraphQLJsonBody({
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify({ query: "query Search", operationName: "Search" }),
|
||||||
|
url: "https://api.example.com/graphql",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not detect plain JSON query fields even on GraphQL-looking paths", () => {
|
||||||
|
expect(
|
||||||
|
isGraphQLJsonBody({
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify({ query: "SearchQueryInput!" }),
|
||||||
|
url: "https://api.example.com/graphql",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not use variables and operationName alone as enough evidence", () => {
|
||||||
|
expect(
|
||||||
|
isGraphQLJsonBody({
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify({
|
||||||
|
query: "SearchQueryInput!",
|
||||||
|
variables: { id: "123" },
|
||||||
|
operationName: "Search",
|
||||||
|
}),
|
||||||
|
url: "https://api.example.com",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects bodies with string variables without parsing them", () => {
|
||||||
|
const args = {
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify({
|
||||||
|
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||||
|
variables: '{ "id": "123" }',
|
||||||
|
}),
|
||||||
|
url: "https://api.example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isGraphQLJsonBody(args)).toBe(true);
|
||||||
|
expect(parseGraphQLJsonBody(args)).toEqual({
|
||||||
|
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||||
|
variables: '{ "id": "123" }',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not detect GraphQL envelopes with extra fields", () => {
|
||||||
|
const args = {
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify({
|
||||||
|
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||||
|
variables: { id: "123" },
|
||||||
|
extensions: { persistedQuery: { version: 1, sha256Hash: "abc123" } },
|
||||||
|
}),
|
||||||
|
url: "https://api.example.com/graphql",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isGraphQLJsonBody(args)).toBe(false);
|
||||||
|
expect(parseGraphQLJsonBody(args)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores invalid JSON and non-object JSON", () => {
|
||||||
|
expect(
|
||||||
|
isGraphQLJsonBody({
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: "not json",
|
||||||
|
url: "https://api.example.com/graphql",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isGraphQLJsonBody({
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: "[]",
|
||||||
|
url: "https://api.example.com/graphql",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores non-JSON MIME types", () => {
|
||||||
|
expect(
|
||||||
|
isGraphQLJsonBody({
|
||||||
|
mimeType: "text/plain",
|
||||||
|
text: JSON.stringify({ query: "query Search { viewer { id } }" }),
|
||||||
|
url: "https://api.example.com/graphql",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -562,6 +562,53 @@ describe("importer-curl", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Imports GraphQL JSON data as a GraphQL request", () => {
|
||||||
|
expect(
|
||||||
|
convertCurl(
|
||||||
|
`curl 'https://yaak.app/graphql' -H 'Content-Type: application/json' --data-raw $'{"query":"query Search($id: ID\\u0021) { node(id: $id) { id } }","variables":{"id":"123"}}'`,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
resources: {
|
||||||
|
workspaces: [baseWorkspace()],
|
||||||
|
httpRequests: [
|
||||||
|
baseRequest({
|
||||||
|
url: "https://yaak.app/graphql",
|
||||||
|
method: "POST",
|
||||||
|
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
|
||||||
|
bodyType: "graphql",
|
||||||
|
body: {
|
||||||
|
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||||
|
variables: '{\n "id": "123"\n}',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Imports GraphQL JSON with extensions as JSON", () => {
|
||||||
|
expect(
|
||||||
|
convertCurl(
|
||||||
|
`curl 'https://yaak.app/graphql' -H 'Content-Type: application/json' --data-raw $'{"query":"query Search($id: ID\\u0021) { node(id: $id) { id } }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"abc123"}}}'`,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
resources: {
|
||||||
|
workspaces: [baseWorkspace()],
|
||||||
|
httpRequests: [
|
||||||
|
baseRequest({
|
||||||
|
url: "https://yaak.app/graphql",
|
||||||
|
method: "POST",
|
||||||
|
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
|
||||||
|
bodyType: "application/json",
|
||||||
|
body: {
|
||||||
|
text: '{"query":"query Search($id: ID!) { node(id: $id) { id } }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"abc123"}}}',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("Imports data with multiple escape sequences", () => {
|
test("Imports data with multiple escape sequences", () => {
|
||||||
expect(
|
expect(
|
||||||
convertCurl(
|
convertCurl(
|
||||||
|
|||||||
Reference in New Issue
Block a user