mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-06 04:45:24 +02:00
Convert request bodies when changing type (#499)
This commit is contained in:
@@ -93,6 +93,7 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
|
||||
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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
} from "@yaakapp/api";
|
||||
import { split } from "shlex";
|
||||
import { parseGraphQLJsonBody } from "./graphql";
|
||||
|
||||
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 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] = {
|
||||
|
||||
@@ -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", () => {
|
||||
expect(
|
||||
convertCurl(
|
||||
|
||||
Reference in New Issue
Block a user