mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-05 20:41:58 +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 { 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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
|
||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
@@ -11,9 +11,13 @@ import type { DropdownItem } from "../core/Dropdown";
|
||||
import { Dropdown } from "../core/Dropdown";
|
||||
import type { EditorProps } from "../core/Editor/Editor";
|
||||
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 { Separator } from "../core/Separator";
|
||||
import { tryFormatGraphql } from "../../lib/formatters";
|
||||
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
||||
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
||||
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
||||
|
||||
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
||||
@@ -22,6 +26,8 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
|
||||
request: HttpRequest;
|
||||
};
|
||||
|
||||
const OPERATION_NAME_NOT_SPECIFIED = "";
|
||||
|
||||
export function GraphQLEditor(props: Props) {
|
||||
// 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.
|
||||
@@ -38,25 +44,25 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
operationName?: string;
|
||||
}>(() => {
|
||||
// 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 ?? "" };
|
||||
return normalizeGraphQLBody(request.body);
|
||||
}, [extraEditorProps.forceUpdateKey]);
|
||||
|
||||
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
|
||||
const isDocOpen = isDocOpenRecord[request.id] !== undefined;
|
||||
const parsedOperationNames = useMemo(
|
||||
() => parseGraphQLOperationNames(currentBody.query),
|
||||
[currentBody.query],
|
||||
);
|
||||
const operationNames = useMemo(() => parsedOperationNames ?? [], [parsedOperationNames]);
|
||||
|
||||
const handleChangeQuery = useCallback(
|
||||
(query: string) => {
|
||||
setCurrentBody(({ variables }) => {
|
||||
const newBody = { query, variables };
|
||||
setCurrentBody(({ variables, operationName }) => {
|
||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||
onChange(newBody);
|
||||
return newBody;
|
||||
});
|
||||
@@ -66,8 +72,8 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
|
||||
const handleChangeVariables = useCallback(
|
||||
(variables: string) => {
|
||||
setCurrentBody(({ query }) => {
|
||||
const newBody = { query, variables: variables || undefined };
|
||||
setCurrentBody(({ query, operationName }) => {
|
||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||
onChange(newBody);
|
||||
return newBody;
|
||||
});
|
||||
@@ -75,125 +81,196 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
[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"]>(
|
||||
() => [
|
||||
<div key="actions" className="flex flex-row opacity-100! shadow!">
|
||||
<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>
|
||||
)}
|
||||
operationNames.length > 0 ? (
|
||||
<div key="operation" className="opacity-100!">
|
||||
<RadioDropdown
|
||||
value={currentBody.operationName ?? operationNames[0] ?? OPERATION_NAME_NOT_SPECIFIED}
|
||||
onChange={handleChangeOperationName}
|
||||
items={[
|
||||
{ type: "separator", label: "Operation Name" },
|
||||
{
|
||||
label: <span className="text-text-subtle italic">Not specified</span>,
|
||||
value: OPERATION_NAME_NOT_SPECIFIED,
|
||||
},
|
||||
...operationNames.map((operationName) => ({
|
||||
label: operationName,
|
||||
value: operationName,
|
||||
})),
|
||||
] satisfies RadioDropdownItem<string>[]}
|
||||
>
|
||||
<Button size="sm" variant="border" title="Select Operation" forDropdown>
|
||||
{currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED ? (
|
||||
<span className="text-text-subtle italic">Not specified</span>
|
||||
) : (
|
||||
currentBody.operationName ?? operationNames[0]
|
||||
)}
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
</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>,
|
||||
],
|
||||
[
|
||||
schema,
|
||||
clear,
|
||||
error,
|
||||
currentBody.operationName,
|
||||
handleChangeOperationName,
|
||||
isDocOpen,
|
||||
isLoading,
|
||||
operationNames,
|
||||
refetch,
|
||||
autoIntrospectDisabled,
|
||||
baseRequest.id,
|
||||
@@ -237,10 +314,23 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
);
|
||||
}
|
||||
|
||||
function tryParseJson(text: string, fallback: unknown) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return fallback;
|
||||
function buildGraphQLBody(body: {
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
operationName?: string;
|
||||
}) {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user