From eb2a2dd775d8e7dd674eacfb2fc1e43cdb7ca90f Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 4 Jul 2026 22:22:35 -0700 Subject: [PATCH] Convert request bodies when changing type (#499) --- .../components/HttpRequestPane.tsx | 10 +- .../components/graphql/GraphQLEditor.tsx | 340 +++++++++++------- .../lib/graphqlOperationNames.test.ts | 37 ++ apps/yaak-client/lib/graphqlOperationNames.ts | 26 ++ .../lib/requestBodyConversion.test.ts | 152 ++++++++ apps/yaak-client/lib/requestBodyConversion.ts | 199 ++++++++++ crates/yaak-http/src/types.rs | 91 ++++- plugins/action-copy-curl/src/index.ts | 1 + plugins/action-copy-curl/tests/index.test.ts | 19 + plugins/importer-curl/src/graphql.ts | 117 ++++++ plugins/importer-curl/src/index.ts | 31 +- plugins/importer-curl/tests/graphql.test.ts | 146 ++++++++ plugins/importer-curl/tests/index.test.ts | 47 +++ 13 files changed, 1065 insertions(+), 151 deletions(-) create mode 100644 apps/yaak-client/lib/graphqlOperationNames.test.ts create mode 100644 apps/yaak-client/lib/graphqlOperationNames.ts create mode 100644 apps/yaak-client/lib/requestBodyConversion.test.ts create mode 100644 apps/yaak-client/lib/requestBodyConversion.ts create mode 100644 plugins/importer-curl/src/graphql.ts create mode 100644 plugins/importer-curl/tests/graphql.test.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..94395a0c 100644 --- a/apps/yaak-client/components/graphql/GraphQLEditor.tsx +++ b/apps/yaak-client/components/graphql/GraphQLEditor.tsx @@ -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 & { @@ -22,6 +26,8 @@ type Props = Pick & 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( () => [ -
-
- {schema === undefined ? null /* Initializing */ : ( - , - }, - { type: "separator" }, - ] - : []) satisfies DropdownItem[]), - { - hidden: !error, - label: ( - -

Schema introspection failed

- -
- - ), - }); - }} - > - View Error - - - ), - type: "content", - }, - { - hidden: schema == null, - label: `${isDocOpen ? "Hide" : "Show"} Documentation`, - leftSlot: , - onSelect: () => { - setGraphqlDocStateAtomValue((v) => ({ - ...v, - [request.id]: isDocOpen ? undefined : null, - })); - }, - }, - { - label: "Introspect Schema", - leftSlot: , - keepOpenOnSelect: true, - onSelect: refetch, - }, - { type: "separator", label: "Setting" }, - { - label: "Automatic Introspection", - keepOpenOnSelect: true, - onSelect: () => { - setAutoIntrospectDisabled({ - ...autoIntrospectDisabled, - [baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id], - }); - }, - leftSlot: ( - - ), - }, - ]} - > - - - )} + operationNames.length > 0 ? ( +
+ Not specified, + value: OPERATION_NAME_NOT_SPECIFIED, + }, + ...operationNames.map((operationName) => ({ + label: operationName, + value: operationName, + })), + ] satisfies RadioDropdownItem[]} + > + +
+ ) : null, +
+ {schema === undefined ? null /* Initializing */ : ( + , + }, + { type: "separator" }, + ] + : []) satisfies DropdownItem[]), + { + hidden: !error, + label: ( + +

Schema introspection failed

+ +
+ + ), + }); + }} + > + View Error + + + ), + type: "content", + }, + { + hidden: schema == null, + label: `${isDocOpen ? "Hide" : "Show"} Documentation`, + leftSlot: , + onSelect: () => { + setGraphqlDocStateAtomValue((v) => ({ + ...v, + [request.id]: isDocOpen ? undefined : null, + })); + }, + }, + { + label: "Introspect Schema", + leftSlot: , + keepOpenOnSelect: true, + onSelect: refetch, + }, + { type: "separator", label: "Setting" }, + { + label: "Automatic Introspection", + keepOpenOnSelect: true, + onSelect: () => { + setAutoIntrospectDisabled({ + ...autoIntrospectDisabled, + [baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id], + }); + }, + leftSlot: ( + + ), + }, + ]} + > + + + )}
, ], [ 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; } diff --git a/apps/yaak-client/lib/graphqlOperationNames.test.ts b/apps/yaak-client/lib/graphqlOperationNames.test.ts new file mode 100644 index 00000000..f6b04cee --- /dev/null +++ b/apps/yaak-client/lib/graphqlOperationNames.test.ts @@ -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(); + }); +}); diff --git a/apps/yaak-client/lib/graphqlOperationNames.ts b/apps/yaak-client/lib/graphqlOperationNames.ts new file mode 100644 index 00000000..7ae9e36e --- /dev/null +++ b/apps/yaak-client/lib/graphqlOperationNames.ts @@ -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; + } +} diff --git a/apps/yaak-client/lib/requestBodyConversion.test.ts b/apps/yaak-client/lib/requestBodyConversion.test.ts new file mode 100644 index 00000000..aeebda3c --- /dev/null +++ b/apps/yaak-client/lib/requestBodyConversion.test.ts @@ -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({}); + }); +}); diff --git a/apps/yaak-client/lib/requestBodyConversion.ts b/apps/yaak-client/lib/requestBodyConversion.ts new file mode 100644 index 00000000..26cff993 --- /dev/null +++ b/apps/yaak-client/lib/requestBodyConversion.ts @@ -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 = { 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 = {}; + + 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 { + return value != null && typeof value === "object" && !Array.isArray(value); +} diff --git a/crates/yaak-http/src/types.rs b/crates/yaak-http/src/types.rs index 0794df21..d7931684 100644 --- a/crates/yaak-http/src/types.rs +++ b/crates/yaak-http/src/types.rs @@ -191,12 +191,16 @@ fn build_url(r: &HttpRequest) -> String { fn append_graphql_query_params(url: &str, body: &BTreeMap) -> String { let query = get_str_map(body, "query").to_string(); 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)]; if !variables.trim().is_empty() { 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 - let url = strip_query_params(url, &["query", "variables"]); + let url = strip_query_params(url, &["query", "variables", "operationName"]); append_query_params(&url, params) } @@ -329,23 +333,30 @@ fn build_graphql_body( ) -> Option { let query = get_str_map(body, "query"); let variables = strip_json_comments(&get_str_map(body, "variables")); + let operation_name = get_str_map(body, "operationName"); if method.to_lowercase() == "get" { // GraphQL GET requests use query parameters, not a body return None; } - let body = if variables.trim().is_empty() { - format!(r#"{{"query":{}}}"#, serde_json::to_string(&query).unwrap_or_default()) - } else { - format!( - r#"{{"query":{},"variables":{}}}"#, - serde_json::to_string(&query).unwrap_or_default(), - variables - ) - }; + let mut body = serde_json::Map::new(); + body.insert("query".to_string(), serde_json::Value::String(query.to_string())); + if !variables.trim().is_empty() { + body.insert( + "variables".to_string(), + serde_json::from_str(&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( @@ -522,6 +533,33 @@ mod tests { 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] fn test_build_url_with_disabled_params() { let r = HttpRequest { @@ -880,9 +918,34 @@ mod tests { let result = build_graphql_body("POST", &body); match result { Some(SendableBodyWithMeta::Bytes(bytes)) => { - let expected = - r#"{"query":"{ user(id: $id) { name } }","variables":{"id": "123"}}"#; - assert_eq!(bytes, Bytes::from(expected)); + assert_eq!( + serde_json::from_slice::(&bytes).unwrap(), + 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::(&bytes).unwrap(), + json!({ + "query": "query Search { viewer { id } }", + "operationName": "Search", + }), + ); } _ => panic!("Expected Some(SendableBody::Bytes)"), } diff --git a/plugins/action-copy-curl/src/index.ts b/plugins/action-copy-curl/src/index.ts index 87733100..4f02afa7 100644 --- a/plugins/action-copy-curl/src/index.ts +++ b/plugins/action-copy-curl/src/index.ts @@ -93,6 +93,7 @@ export async function convertToCurl(request: Partial) { const body = { query: request.body.query || "", variables: maybeParseJSON(request.body.variables, undefined), + operationName: request.body.operationName || undefined, }; xs.push("--data", quote(JSON.stringify(body))); xs.push(NEWLINE); diff --git a/plugins/action-copy-curl/tests/index.test.ts b/plugins/action-copy-curl/tests/index.test.ts index 86fb301d..824cf718 100644 --- a/plugins/action-copy-curl/tests/index.test.ts +++ b/plugins/action-copy-curl/tests/index.test.ts @@ -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 () => { expect( await convertToCurl({ diff --git a/plugins/importer-curl/src/graphql.ts b/plugins/importer-curl/src/graphql.ts new file mode 100644 index 00000000..21743b59 --- /dev/null +++ b/plugins/importer-curl/src/graphql.ts @@ -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; + 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): boolean { + const allowedKeys = new Set(["query", "variables", "operationName"]); + return Object.keys(body).some((key) => !allowedKeys.has(key)); +} + +function getGraphQLDetectionSignals( + body: Record, + 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; + } +} diff --git a/plugins/importer-curl/src/index.ts b/plugins/importer-curl/src/index.ts index ca7d5867..d03e31a2 100644 --- a/plugins/importer-curl/src/index.ts +++ b/plugins/importer-curl/src/index.ts @@ -8,6 +8,7 @@ import type { Workspace, } from "@yaakapp/api"; import { split } from "shlex"; +import { parseGraphQLJsonBody } from "./graphql"; type AtLeast = Partial & Pick; @@ -464,6 +465,8 @@ function importCommand(parseEntries: string[], workspaceId: string) { let body = {}; let bodyType: string | null = null; const bodyAsGET = getPairValue(flagsByName, false, ["G", "get"]); + const hasDataBody = dataParameters.length > 0 && !bodyAsGET; + const hasFormBody = multipartFormDataFromRaw != null || formDataParams.length > 0; if (multipartFormDataFromRaw) { // Handle multipart form data parsed from --data-raw (Chrome DevTools format) @@ -491,15 +494,21 @@ function importCommand(parseEntries: string[], workspaceId: string) { enabled: true, }); } else if (dataParameters.length > 0) { - bodyType = - mimeType === "application/json" || mimeType === "text/xml" || mimeType === "text/plain" - ? mimeType - : "other"; - body = { - text: dataParameters - .map(({ name, value }) => (name && value ? `${name}=${value}` : name || value)) - .join("&"), - }; + const text = dataParameters + .map(({ name, value }) => (name && value ? `${name}=${value}` : name || value)) + .join("&"); + const graphqlBody = parseGraphQLJsonBody({ mimeType, text, url }); + + if (graphqlBody != null) { + bodyType = "graphql"; + 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) { bodyType = mimeType ?? "multipart/form-data"; body = { @@ -517,8 +526,8 @@ function importCommand(parseEntries: string[], workspaceId: string) { // Method let method = getPairValue(flagsByName, "", ["X", "request"]).toUpperCase(); - if (method === "" && body) { - method = "text" in body || "form" in body ? "POST" : "GET"; + if (method === "") { + method = hasDataBody || hasFormBody ? "POST" : "GET"; } const request: ExportResources["httpRequests"][0] = { diff --git a/plugins/importer-curl/tests/graphql.test.ts b/plugins/importer-curl/tests/graphql.test.ts new file mode 100644 index 00000000..756ae619 --- /dev/null +++ b/plugins/importer-curl/tests/graphql.test.ts @@ -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); + }); +}); diff --git a/plugins/importer-curl/tests/index.test.ts b/plugins/importer-curl/tests/index.test.ts index ab25b1e9..6852ab09 100644 --- a/plugins/importer-curl/tests/index.test.ts +++ b/plugins/importer-curl/tests/index.test.ts @@ -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", () => { expect( convertCurl(