Merge main into proxy branch (formatting and docs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-03-13 12:09:59 -07:00
parent 3c4035097a
commit 7314aedc71
712 changed files with 13408 additions and 13322 deletions

View File

@@ -1,13 +1,13 @@
{
"name": "@yaak/importer-curl",
"displayName": "cURL Importer",
"description": "Import requests from cURL commands",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Import requests from cURL commands",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vitest --run tests"
"test": "vp test --run tests"
},
"dependencies": {
"shlex": "^3.0.0"

View File

@@ -6,38 +6,38 @@ import type {
HttpUrlParameter,
PluginDefinition,
Workspace,
} from '@yaakapp/api';
import { split } from 'shlex';
} from "@yaakapp/api";
import { split } from "shlex";
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface ExportResources {
workspaces: AtLeast<Workspace, 'name' | 'id' | 'model'>[];
environments: AtLeast<Environment, 'name' | 'id' | 'model' | 'workspaceId'>[];
httpRequests: AtLeast<HttpRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
workspaces: AtLeast<Workspace, "name" | "id" | "model">[];
environments: AtLeast<Environment, "name" | "id" | "model" | "workspaceId">[];
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
}
const DATA_FLAGS = ['d', 'data', 'data-raw', 'data-urlencode', 'data-binary', 'data-ascii'];
const DATA_FLAGS = ["d", "data", "data-raw", "data-urlencode", "data-binary", "data-ascii"];
const SUPPORTED_FLAGS = [
['cookie', 'b'],
['d', 'data'], // Add url encoded data
['data-ascii'],
['data-binary'],
['data-raw'],
['data-urlencode'],
['digest'], // Apply auth as digest
['form', 'F'], // Add multipart data
['get', 'G'], // Put the post data in the URL
['header', 'H'],
['request', 'X'], // Request method
['url'], // Specify the URL explicitly
['url-query'],
['user', 'u'], // Authentication
["cookie", "b"],
["d", "data"], // Add url encoded data
["data-ascii"],
["data-binary"],
["data-raw"],
["data-urlencode"],
["digest"], // Apply auth as digest
["form", "F"], // Add multipart data
["get", "G"], // Put the post data in the URL
["header", "H"],
["request", "X"], // Request method
["url"], // Specify the URL explicitly
["url-query"],
["user", "u"], // Authentication
DATA_FLAGS,
].flat();
const BOOLEAN_FLAGS = ['G', 'get', 'digest'];
const BOOLEAN_FLAGS = ["G", "get", "digest"];
type FlagValue = string | boolean;
@@ -45,10 +45,10 @@ type FlagsByName = Record<string, FlagValue[]>;
export const plugin: PluginDefinition = {
importer: {
name: 'cURL',
description: 'Import cURL commands',
name: "cURL",
description: "Import cURL commands",
onImport(_ctx: Context, args: { text: string }) {
// biome-ignore lint/suspicious/noExplicitAny: none
// oxlint-disable-next-line no-explicit-any
return convertCurl(args.text) as any;
},
},
@@ -60,14 +60,14 @@ export const plugin: PluginDefinition = {
*/
function splitCommands(rawData: string): string[] {
// Join line continuations (backslash-newline, and backslash-CRLF for Windows)
const joined = rawData.replace(/\\\r?\n/g, ' ');
const joined = rawData.replace(/\\\r?\n/g, " ");
// Count consecutive backslashes immediately before position i.
// An even count means the quote at i is NOT escaped; odd means it IS escaped.
function isEscaped(i: number): boolean {
let backslashes = 0;
let j = i - 1;
while (j >= 0 && joined[j] === '\\') {
while (j >= 0 && joined[j] === "\\") {
backslashes++;
j--;
}
@@ -76,7 +76,7 @@ function splitCommands(rawData: string): string[] {
// Split on semicolons and newlines to separate commands
const commands: string[] = [];
let current = '';
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let inDollarQuote = false;
@@ -108,7 +108,7 @@ function splitCommands(rawData: string): string[] {
current += ch;
continue;
}
if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && ch === '$' && next === "'") {
if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && ch === "$" && next === "'") {
inDollarQuote = true;
current += ch + next;
i++; // Skip the opening quote
@@ -126,13 +126,13 @@ function splitCommands(rawData: string): string[] {
if (
!inQuote &&
!isEscaped(i) &&
(ch === ';' || ch === '\n' || (ch === '\r' && next === '\n'))
(ch === ";" || ch === "\n" || (ch === "\r" && next === "\n"))
) {
if (ch === '\r') i++; // Skip the \n in \r\n
if (ch === "\r") i++; // Skip the \n in \r\n
if (current.trim()) {
commands.push(current.trim());
}
current = '';
current = "";
continue;
}
@@ -156,21 +156,21 @@ export function convertCurl(rawData: string) {
// Break up squished arguments like `-XPOST` into `-X POST`
return tokens.flatMap((token) => {
if (token.startsWith('-') && !token.startsWith('--') && token.length > 2) {
if (token.startsWith("-") && !token.startsWith("--") && token.length > 2) {
return [token.slice(0, 2), token.slice(2)];
}
return token;
});
});
const workspace: ExportResources['workspaces'][0] = {
model: 'workspace',
id: generateId('workspace'),
name: 'Curl Import',
const workspace: ExportResources["workspaces"][0] = {
model: "workspace",
id: generateId("workspace"),
name: "Curl Import",
};
const requests: ExportResources['httpRequests'] = commands
.filter((command) => command[0] === 'curl')
const requests: ExportResources["httpRequests"] = commands
.filter((command) => command[0] === "curl")
.map((v) => importCommand(v, workspace.id));
return {
@@ -191,13 +191,13 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// Start at 1 so we can skip the ^curl part
for (let i = 1; i < parseEntries.length; i++) {
let parseEntry = parseEntries[i];
if (typeof parseEntry === 'string') {
if (typeof parseEntry === "string") {
parseEntry = parseEntry.trim();
}
if (typeof parseEntry === 'string' && parseEntry.match(/^-{1,2}[\w-]+/)) {
const isSingleDash = parseEntry[0] === '-' && parseEntry[1] !== '-';
let name = parseEntry.replace(/^-{1,2}/, '');
if (typeof parseEntry === "string" && parseEntry.match(/^-{1,2}[\w-]+/)) {
const isSingleDash = parseEntry[0] === "-" && parseEntry[1] !== "-";
let name = parseEntry.replace(/^-{1,2}/, "");
if (!SUPPORTED_FLAGS.includes(name)) {
continue;
@@ -211,13 +211,13 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// - Double dash followed by a letter: --data-raw, --header
// This prevents mistaking data that starts with dashes (like multipart boundaries ------) as flags
const nextEntryIsFlag =
typeof nextEntry === 'string' &&
typeof nextEntry === "string" &&
(nextEntry.match(/^-[a-zA-Z]/) || nextEntry.match(/^--[a-zA-Z]/));
if (isSingleDash && name.length > 1) {
// Handle squished arguments like -XPOST
value = name.slice(1);
name = name.slice(0, 1);
} else if (typeof nextEntry === 'string' && hasValue && !nextEntryIsFlag) {
} else if (typeof nextEntry === "string" && hasValue && !nextEntryIsFlag) {
// Next arg is not a flag, so assign it as the value
value = nextEntry;
i++; // Skip next one
@@ -236,14 +236,14 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// Build the request //
// ~~~~~~~~~~~~~~~~~ //
const urlArg = getPairValue(flagsByName, (singletons[0] as string) || '', ['url']);
const [baseUrl, search] = splitOnce(urlArg, '?');
const urlArg = getPairValue(flagsByName, (singletons[0] as string) || "", ["url"]);
const [baseUrl, search] = splitOnce(urlArg, "?");
const urlParameters: HttpUrlParameter[] =
search?.split('&').map((p) => {
const v = splitOnce(p, '=');
search?.split("&").map((p) => {
const v = splitOnce(p, "=");
return {
name: decodeURIComponent(v[0] ?? ''),
value: decodeURIComponent(v[1] ?? ''),
name: decodeURIComponent(v[0] ?? ""),
value: decodeURIComponent(v[1] ?? ""),
enabled: true,
};
}) ?? [];
@@ -251,27 +251,27 @@ function importCommand(parseEntries: string[], workspaceId: string) {
const url = baseUrl ?? urlArg;
// Query params
for (const p of flagsByName['url-query'] ?? []) {
if (typeof p !== 'string') {
for (const p of flagsByName["url-query"] ?? []) {
if (typeof p !== "string") {
continue;
}
const [name, value] = p.split('=');
const [name, value] = p.split("=");
urlParameters.push({
name: name ?? '',
value: value ?? '',
name: name ?? "",
value: value ?? "",
enabled: true,
});
}
// Authentication
const [username, password] = getPairValue(flagsByName, '', ['u', 'user']).split(/:(.*)$/);
const [username, password] = getPairValue(flagsByName, "", ["u", "user"]).split(/:(.*)$/);
const isDigest = getPairValue(flagsByName, false, ['digest']);
const authenticationType = username ? (isDigest ? 'digest' : 'basic') : null;
const isDigest = getPairValue(flagsByName, false, ["digest"]);
const authenticationType = username ? (isDigest ? "digest" : "basic") : null;
const authentication = username
? {
username: username.trim(),
password: (password ?? '').trim(),
password: (password ?? "").trim(),
}
: {};
@@ -284,13 +284,13 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// remove final colon from header name if present
if (!value) {
return {
name: (name ?? '').trim().replace(/;$/, ''),
value: '',
name: (name ?? "").trim().replace(/;$/, ""),
value: "",
enabled: true,
};
}
return {
name: (name ?? '').trim(),
name: (name ?? "").trim(),
value: value.trim(),
enabled: true,
};
@@ -302,14 +302,14 @@ function importCommand(parseEntries: string[], workspaceId: string) {
...((flagsByName.b as string[] | undefined) || []),
]
.map((str) => {
const name = str.split('=', 1)[0];
const value = str.replace(`${name}=`, '');
const name = str.split("=", 1)[0];
const value = str.replace(`${name}=`, "");
return `${name}=${value}`;
})
.join('; ');
.join("; ");
// Convert cookie value to header
const existingCookieHeader = headers.find((header) => header.name.toLowerCase() === 'cookie');
const existingCookieHeader = headers.find((header) => header.name.toLowerCase() === "cookie");
if (cookieHeaderValue && existingCookieHeader) {
// Has existing cookie header, so let's update it
@@ -317,15 +317,15 @@ function importCommand(parseEntries: string[], workspaceId: string) {
} else if (cookieHeaderValue) {
// No existing cookie header, so let's make a new one
headers.push({
name: 'Cookie',
name: "Cookie",
value: cookieHeaderValue,
enabled: true,
});
}
// Body (Text or Blob)
const contentTypeHeader = headers.find((header) => header.name.toLowerCase() === 'content-type');
const mimeType = contentTypeHeader ? contentTypeHeader.value.split(';')[0]?.trim() : null;
const contentTypeHeader = headers.find((header) => header.name.toLowerCase() === "content-type");
const mimeType = contentTypeHeader ? contentTypeHeader.value.split(";")[0]?.trim() : null;
// Extract boundary from Content-Type header for multipart parsing
const boundaryMatch = contentTypeHeader?.value.match(/boundary=([^\s;]+)/i);
@@ -333,19 +333,19 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// Get raw data from --data-raw flags (before splitting by &)
const rawDataValues = [
...((flagsByName['data-raw'] as string[] | undefined) || []),
...((flagsByName["data-raw"] as string[] | undefined) || []),
...((flagsByName.d as string[] | undefined) || []),
...((flagsByName.data as string[] | undefined) || []),
...((flagsByName['data-binary'] as string[] | undefined) || []),
...((flagsByName['data-ascii'] as string[] | undefined) || []),
...((flagsByName["data-binary"] as string[] | undefined) || []),
...((flagsByName["data-ascii"] as string[] | undefined) || []),
];
// Check if this is multipart form data in --data-raw (Chrome DevTools format)
let multipartFormDataFromRaw:
| { name: string; value?: string; file?: string; enabled: boolean }[]
| null = null;
if (mimeType === 'multipart/form-data' && boundary && rawDataValues.length > 0) {
const rawBody = rawDataValues.join('');
if (mimeType === "multipart/form-data" && boundary && rawDataValues.length > 0) {
const rawBody = rawDataValues.join("");
multipartFormDataFromRaw = parseMultipartFormData(rawBody, boundary);
}
@@ -356,15 +356,15 @@ function importCommand(parseEntries: string[], workspaceId: string) {
...((flagsByName.form as string[] | undefined) || []),
...((flagsByName.F as string[] | undefined) || []),
].map((str) => {
const parts = str.split('=');
const name = parts[0] ?? '';
const value = parts[1] ?? '';
const parts = str.split("=");
const name = parts[0] ?? "";
const value = parts[1] ?? "";
const item: { name: string; value?: string; file?: string; enabled: boolean } = {
name,
enabled: true,
};
if (value.indexOf('@') === 0) {
if (value.indexOf("@") === 0) {
item.file = value.slice(1);
} else {
item.value = value;
@@ -376,11 +376,11 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// Body
let body = {};
let bodyType: string | null = null;
const bodyAsGET = getPairValue(flagsByName, false, ['G', 'get']);
const bodyAsGET = getPairValue(flagsByName, false, ["G", "get"]);
if (multipartFormDataFromRaw) {
// Handle multipart form data parsed from --data-raw (Chrome DevTools format)
bodyType = 'multipart/form-data';
bodyType = "multipart/form-data";
body = {
form: multipartFormDataFromRaw,
};
@@ -388,57 +388,57 @@ function importCommand(parseEntries: string[], workspaceId: string) {
urlParameters.push(...dataParameters);
} else if (
dataParameters.length > 0 &&
(mimeType == null || mimeType === 'application/x-www-form-urlencoded')
(mimeType == null || mimeType === "application/x-www-form-urlencoded")
) {
bodyType = mimeType ?? 'application/x-www-form-urlencoded';
bodyType = mimeType ?? "application/x-www-form-urlencoded";
body = {
form: dataParameters.map((parameter) => ({
...parameter,
name: decodeURIComponent(parameter.name || ''),
value: decodeURIComponent(parameter.value || ''),
name: decodeURIComponent(parameter.name || ""),
value: decodeURIComponent(parameter.value || ""),
})),
};
headers.push({
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
});
} else if (dataParameters.length > 0) {
bodyType =
mimeType === 'application/json' || mimeType === 'text/xml' || mimeType === 'text/plain'
mimeType === "application/json" || mimeType === "text/xml" || mimeType === "text/plain"
? mimeType
: 'other';
: "other";
body = {
text: dataParameters
.map(({ name, value }) => (name && value ? `${name}=${value}` : name || value))
.join('&'),
.join("&"),
};
} else if (formDataParams.length) {
bodyType = mimeType ?? 'multipart/form-data';
bodyType = mimeType ?? "multipart/form-data";
body = {
form: formDataParams,
};
if (mimeType == null) {
headers.push({
name: 'Content-Type',
value: 'multipart/form-data',
name: "Content-Type",
value: "multipart/form-data",
enabled: true,
});
}
}
// Method
let method = getPairValue(flagsByName, '', ['X', 'request']).toUpperCase();
let method = getPairValue(flagsByName, "", ["X", "request"]).toUpperCase();
if (method === '' && body) {
method = 'text' in body || 'form' in body ? 'POST' : 'GET';
if (method === "" && body) {
method = "text" in body || "form" in body ? "POST" : "GET";
}
const request: ExportResources['httpRequests'][0] = {
id: generateId('http_request'),
model: 'http_request',
const request: ExportResources["httpRequests"][0] = {
id: generateId("http_request"),
model: "http_request",
workspaceId,
name: '',
name: "",
urlParameters,
url,
method,
@@ -473,22 +473,22 @@ function pairsToDataParameters(keyedPairs: FlagsByName): DataParameter[] {
}
for (const p of pairs) {
if (typeof p !== 'string') continue;
const params = p.split('&');
if (typeof p !== "string") continue;
const params = p.split("&");
for (const param of params) {
const [name, value] = splitOnce(param, '=');
if (param.startsWith('@')) {
const [name, value] = splitOnce(param, "=");
if (param.startsWith("@")) {
// Yaak doesn't support files in url-encoded data, so
dataParameters.push({
name: name ?? '',
value: '',
name: name ?? "",
value: "",
filePath: param.slice(1),
enabled: true,
});
} else {
dataParameters.push({
name: name ?? '',
value: flagName === 'data-urlencode' ? encodeURIComponent(value ?? '') : (value ?? ''),
name: name ?? "",
value: flagName === "data-urlencode" ? encodeURIComponent(value ?? "") : (value ?? ""),
enabled: true,
});
}
@@ -537,12 +537,12 @@ function parseMultipartFormData(
for (const part of parts) {
// Skip empty parts and the closing boundary marker
if (!part || part.trim() === '--' || part.trim() === '--\r\n') {
if (!part || part.trim() === "--" || part.trim() === "--\r\n") {
continue;
}
// Each part has headers and content separated by \r\n\r\n
const headerContentSplit = part.indexOf('\r\n\r\n');
const headerContentSplit = part.indexOf("\r\n\r\n");
if (headerContentSplit === -1) {
continue;
}
@@ -551,7 +551,7 @@ function parseMultipartFormData(
let content = part.slice(headerContentSplit + 4); // Skip \r\n\r\n
// Remove trailing \r\n from content
if (content.endsWith('\r\n')) {
if (content.endsWith("\r\n")) {
content = content.slice(0, -2);
}
@@ -564,7 +564,7 @@ function parseMultipartFormData(
continue;
}
const name = contentDispositionMatch[1] ?? '';
const name = contentDispositionMatch[1] ?? "";
const filename = contentDispositionMatch[2];
const item: { name: string; value?: string; file?: string; enabled: boolean } = {

View File

@@ -1,159 +1,182 @@
import type { HttpRequest, Workspace } from '@yaakapp/api';
import { describe, expect, test } from 'vitest';
import { convertCurl } from '../src';
import type { HttpRequest, Workspace } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { convertCurl } from "../src";
describe('importer-curl', () => {
test('Imports basic GET', () => {
expect(convertCurl('curl https://yaak.app')).toEqual({
describe("importer-curl", () => {
test("Imports basic GET", () => {
expect(convertCurl("curl https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
}),
],
},
});
});
test('Explicit URL', () => {
expect(convertCurl('curl --url https://yaak.app')).toEqual({
test("Explicit URL", () => {
expect(convertCurl("curl --url https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
}),
],
},
});
});
test('Missing URL', () => {
expect(convertCurl('curl -X POST')).toEqual({
test("Missing URL", () => {
expect(convertCurl("curl -X POST")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
method: "POST",
}),
],
},
});
});
test('URL between', () => {
expect(convertCurl('curl -v https://yaak.app -X POST')).toEqual({
test("URL between", () => {
expect(convertCurl("curl -v https://yaak.app -X POST")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
url: "https://yaak.app",
method: "POST",
}),
],
},
});
});
test('Random flags', () => {
expect(convertCurl('curl --random -Z -Y -S --foo https://yaak.app')).toEqual({
test("Random flags", () => {
expect(convertCurl("curl --random -Z -Y -S --foo https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
}),
],
},
});
});
test('Imports --request method', () => {
expect(convertCurl('curl --request POST https://yaak.app')).toEqual({
test("Imports --request method", () => {
expect(convertCurl("curl --request POST https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
url: "https://yaak.app",
method: "POST",
}),
],
},
});
});
test('Imports -XPOST method', () => {
expect(convertCurl('curl -XPOST --request POST https://yaak.app')).toEqual({
test("Imports -XPOST method", () => {
expect(convertCurl("curl -XPOST --request POST https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
url: "https://yaak.app",
method: "POST",
}),
],
},
});
});
test('Imports multiple requests', () => {
test("Imports multiple requests", () => {
expect(
convertCurl('curl \\\n https://yaak.app\necho "foo"\ncurl example.com;curl foo.com'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({ url: 'https://yaak.app' }),
baseRequest({ url: 'example.com' }),
baseRequest({ url: 'foo.com' }),
baseRequest({ url: "https://yaak.app" }),
baseRequest({ url: "example.com" }),
baseRequest({ url: "foo.com" }),
],
},
});
});
test('Imports with Windows CRLF line endings', () => {
expect(
convertCurl('curl \\\r\n -X POST \\\r\n https://yaak.app'),
).toEqual({
test("Imports with Windows CRLF line endings", () => {
expect(convertCurl("curl \\\r\n -X POST \\\r\n https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({ url: 'https://yaak.app', method: 'POST' }),
],
httpRequests: [baseRequest({ url: "https://yaak.app", method: "POST" })],
},
});
});
test('Throws on malformed quotes', () => {
expect(() =>
convertCurl('curl -X POST -F "a=aaa" -F b=bbb" https://yaak.app'),
).toThrow();
test("Throws on malformed quotes", () => {
expect(() => convertCurl('curl -X POST -F "a=aaa" -F b=bbb" https://yaak.app')).toThrow();
});
test('Imports form data', () => {
expect(
convertCurl('curl -X POST -F "a=aaa" -F b=bbb -F f=@filepath https://yaak.app'),
).toEqual({
test("Imports form data", () => {
expect(convertCurl('curl -X POST -F "a=aaa" -F b=bbb -F f=@filepath https://yaak.app')).toEqual(
{
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: "POST",
url: "https://yaak.app",
headers: [
{
name: "Content-Type",
value: "multipart/form-data",
enabled: true,
},
],
bodyType: "multipart/form-data",
body: {
form: [
{ enabled: true, name: "a", value: "aaa" },
{ enabled: true, name: "b", value: "bbb" },
{ enabled: true, name: "f", file: "filepath" },
],
},
}),
],
},
},
);
});
test("Imports data params as form url-encoded", () => {
expect(convertCurl("curl -d a -d b -d c=ccc https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
method: "POST",
url: "https://yaak.app",
bodyType: "application/x-www-form-urlencoded",
headers: [
{
name: 'Content-Type',
value: 'multipart/form-data',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
},
],
bodyType: 'multipart/form-data',
body: {
form: [
{ enabled: true, name: 'a', value: 'aaa' },
{ enabled: true, name: 'b', value: 'bbb' },
{ enabled: true, name: 'f', file: 'filepath' },
{ name: "a", value: "", enabled: true },
{ name: "b", value: "", enabled: true },
{ name: "c", value: "ccc", enabled: true },
],
},
}),
@@ -162,56 +185,27 @@ describe('importer-curl', () => {
});
});
test('Imports data params as form url-encoded', () => {
expect(convertCurl('curl -d a -d b -d c=ccc https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
bodyType: 'application/x-www-form-urlencoded',
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
},
],
body: {
form: [
{ name: 'a', value: '', enabled: true },
{ name: 'b', value: '', enabled: true },
{ name: 'c', value: 'ccc', enabled: true },
],
},
}),
],
},
});
});
test('Imports combined data params as form url-encoded', () => {
test("Imports combined data params as form url-encoded", () => {
expect(convertCurl(`curl -d 'a=aaa&b=bbb&c' https://yaak.app`)).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
bodyType: 'application/x-www-form-urlencoded',
method: "POST",
url: "https://yaak.app",
bodyType: "application/x-www-form-urlencoded",
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
},
],
body: {
form: [
{ name: 'a', value: 'aaa', enabled: true },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: '', enabled: true },
{ name: "a", value: "aaa", enabled: true },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "", enabled: true },
],
},
}),
@@ -220,38 +214,38 @@ describe('importer-curl', () => {
});
});
test('Imports data params as text', () => {
test("Imports data params as text", () => {
expect(
convertCurl('curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app'),
convertCurl("curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app"),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
headers: [{ name: 'Content-Type', value: 'text/plain', enabled: true }],
bodyType: 'text/plain',
body: { text: 'a&b&c=ccc' },
method: "POST",
url: "https://yaak.app",
headers: [{ name: "Content-Type", value: "text/plain", enabled: true }],
bodyType: "text/plain",
body: { text: "a&b&c=ccc" },
}),
],
},
});
});
test('Imports post data into URL', () => {
expect(convertCurl('curl -G https://api.stripe.com/v1/payment_links -d limit=3')).toEqual({
test("Imports post data into URL", () => {
expect(convertCurl("curl -G https://api.stripe.com/v1/payment_links -d limit=3")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'GET',
url: 'https://api.stripe.com/v1/payment_links',
method: "GET",
url: "https://api.stripe.com/v1/payment_links",
urlParameters: [
{
enabled: true,
name: 'limit',
value: '3',
name: "limit",
value: "3",
},
],
}),
@@ -260,7 +254,7 @@ describe('importer-curl', () => {
});
});
test('Imports multi-line JSON', () => {
test("Imports multi-line JSON", () => {
expect(
convertCurl(
`curl -H Content-Type:application/json -d $'{\n "foo":"bar"\n}' https://yaak.app`,
@@ -270,10 +264,10 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
headers: [{ name: 'Content-Type', value: 'application/json', enabled: true }],
bodyType: 'application/json',
method: "POST",
url: "https://yaak.app",
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
bodyType: "application/json",
body: { text: '{\n "foo":"bar"\n}' },
}),
],
@@ -281,20 +275,20 @@ describe('importer-curl', () => {
});
});
test('Imports multiple headers', () => {
test("Imports multiple headers", () => {
expect(
convertCurl('curl -H Foo:bar --header Name -H AAA:bbb -H :ccc https://yaak.app'),
convertCurl("curl -H Foo:bar --header Name -H AAA:bbb -H :ccc https://yaak.app"),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
headers: [
{ name: 'Name', value: '', enabled: true },
{ name: 'Foo', value: 'bar', enabled: true },
{ name: 'AAA', value: 'bbb', enabled: true },
{ name: '', value: 'ccc', enabled: true },
{ name: "Name", value: "", enabled: true },
{ name: "Foo", value: "bar", enabled: true },
{ name: "AAA", value: "bbb", enabled: true },
{ name: "", value: "ccc", enabled: true },
],
}),
],
@@ -302,17 +296,17 @@ describe('importer-curl', () => {
});
});
test('Imports basic auth', () => {
expect(convertCurl('curl --user user:pass https://yaak.app')).toEqual({
test("Imports basic auth", () => {
expect(convertCurl("curl --user user:pass https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
authenticationType: 'basic',
url: "https://yaak.app",
authenticationType: "basic",
authentication: {
username: 'user',
password: 'pass',
username: "user",
password: "pass",
},
}),
],
@@ -320,17 +314,17 @@ describe('importer-curl', () => {
});
});
test('Imports digest auth', () => {
expect(convertCurl('curl --digest --user user:pass https://yaak.app')).toEqual({
test("Imports digest auth", () => {
expect(convertCurl("curl --digest --user user:pass https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
authenticationType: 'digest',
url: "https://yaak.app",
authenticationType: "digest",
authentication: {
username: 'user',
password: 'pass',
username: "user",
password: "pass",
},
}),
],
@@ -338,30 +332,30 @@ describe('importer-curl', () => {
});
});
test('Imports cookie as header', () => {
test("Imports cookie as header", () => {
expect(convertCurl('curl --cookie "foo=bar" https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
headers: [{ name: 'Cookie', value: 'foo=bar', enabled: true }],
url: "https://yaak.app",
headers: [{ name: "Cookie", value: "foo=bar", enabled: true }],
}),
],
},
});
});
test('Imports query params', () => {
test("Imports query params", () => {
expect(convertCurl('curl "https://yaak.app" --url-query foo=bar --url-query baz=qux')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
urlParameters: [
{ name: 'foo', value: 'bar', enabled: true },
{ name: 'baz', value: 'qux', enabled: true },
{ name: "foo", value: "bar", enabled: true },
{ name: "baz", value: "qux", enabled: true },
],
}),
],
@@ -369,16 +363,16 @@ describe('importer-curl', () => {
});
});
test('Imports query params from the URL', () => {
test("Imports query params from the URL", () => {
expect(convertCurl('curl "https://yaak.app?foo=bar&baz=a%20a"')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
urlParameters: [
{ name: 'foo', value: 'bar', enabled: true },
{ name: 'baz', value: 'a a', enabled: true },
{ name: "foo", value: "bar", enabled: true },
{ name: "baz", value: "a a", enabled: true },
],
}),
],
@@ -386,23 +380,23 @@ describe('importer-curl', () => {
});
});
test('Imports weird body', () => {
test("Imports weird body", () => {
expect(convertCurl(`curl 'https://yaak.app' -X POST --data-raw 'foo=bar=baz'`)).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
url: "https://yaak.app",
method: "POST",
bodyType: "application/x-www-form-urlencoded",
body: {
form: [{ name: 'foo', value: 'bar=baz', enabled: true }],
form: [{ name: "foo", value: "bar=baz", enabled: true }],
},
headers: [
{
enabled: true,
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
},
],
}),
@@ -411,7 +405,7 @@ describe('importer-curl', () => {
});
});
test('Imports data with Unicode escape sequences', () => {
test("Imports data with Unicode escape sequences", () => {
expect(
convertCurl(
`curl 'https://yaak.app' -H 'Content-Type: application/json' --data-raw $'{"query":"SearchQueryInput\\u0021"}' -X POST`,
@@ -421,10 +415,10 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
headers: [{ name: 'Content-Type', value: 'application/json', enabled: true }],
bodyType: 'application/json',
url: "https://yaak.app",
method: "POST",
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
bodyType: "application/json",
body: { text: '{"query":"SearchQueryInput!"}' },
}),
],
@@ -432,7 +426,7 @@ describe('importer-curl', () => {
});
});
test('Imports data with multiple escape sequences', () => {
test("Imports data with multiple escape sequences", () => {
expect(
convertCurl(
`curl 'https://yaak.app' --data-raw $'Line1\\nLine2\\tTab\\u0021Exclamation' -X POST`,
@@ -442,17 +436,17 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
url: "https://yaak.app",
method: "POST",
bodyType: "application/x-www-form-urlencoded",
body: {
form: [{ name: 'Line1\nLine2\tTab!Exclamation', value: '', enabled: true }],
form: [{ name: "Line1\nLine2\tTab!Exclamation", value: "", enabled: true }],
},
headers: [
{
enabled: true,
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
},
],
}),
@@ -461,7 +455,7 @@ describe('importer-curl', () => {
});
});
test('Imports multipart form data from --data-raw (Chrome DevTools format)', () => {
test("Imports multipart form data from --data-raw (Chrome DevTools format)", () => {
// This is the format Chrome DevTools uses when copying a multipart form submission as cURL
const curlCommand = `curl 'http://localhost:8080/system' \
-H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd' \
@@ -472,21 +466,21 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'http://localhost:8080/system',
method: 'POST',
url: "http://localhost:8080/system",
method: "POST",
headers: [
{
name: 'Content-Type',
value: 'multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd',
name: "Content-Type",
value: "multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd",
enabled: true,
},
],
bodyType: 'multipart/form-data',
bodyType: "multipart/form-data",
body: {
form: [
{ name: 'username', value: 'jsgj', enabled: true },
{ name: 'password', value: '654321', enabled: true },
{ name: 'captcha', file: 'test.xlsx', enabled: true },
{ name: "username", value: "jsgj", enabled: true },
{ name: "password", value: "654321", enabled: true },
{ name: "captcha", file: "test.xlsx", enabled: true },
],
},
}),
@@ -495,7 +489,7 @@ describe('importer-curl', () => {
});
});
test('Imports JSON body with newlines in $quotes', () => {
test("Imports JSON body with newlines in $quotes", () => {
expect(
convertCurl(
`curl 'https://yaak.app' -H 'Content-Type: application/json' --data-raw $'{\\n "foo": "bar",\\n "baz": "qux"\\n}' -X POST`,
@@ -505,10 +499,10 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
headers: [{ name: 'Content-Type', value: 'application/json', enabled: true }],
bodyType: 'application/json',
url: "https://yaak.app",
method: "POST",
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
bodyType: "application/json",
body: { text: '{\n "foo": "bar",\n "baz": "qux"\n}' },
}),
],
@@ -516,110 +510,94 @@ describe('importer-curl', () => {
});
});
test('Handles double-quoted string ending with even backslashes before semicolon', () => {
test("Handles double-quoted string ending with even backslashes before semicolon", () => {
// "C:\\" has two backslashes which escape each other, so the closing " is real.
// The ; after should split into a second command.
expect(
convertCurl(
'curl -d "C:\\\\" https://yaak.app;curl https://example.com',
),
).toEqual({
expect(convertCurl('curl -d "C:\\\\" https://yaak.app;curl https://example.com')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
url: "https://yaak.app",
method: "POST",
bodyType: "application/x-www-form-urlencoded",
body: {
form: [{ name: 'C:\\', value: '', enabled: true }],
form: [{ name: "C:\\", value: "", enabled: true }],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
},
],
}),
baseRequest({ url: 'https://example.com' }),
baseRequest({ url: "https://example.com" }),
],
},
});
});
test('Handles $quoted string ending with a literal backslash before semicolon', () => {
test("Handles $quoted string ending with a literal backslash before semicolon", () => {
// $'C:\\\\' has two backslashes which become one literal backslash.
// The closing ' must not be misinterpreted as escaped.
// The ; after should split into a second command.
expect(
convertCurl(
"curl -d $'C:\\\\' https://yaak.app;curl https://example.com",
),
).toEqual({
expect(convertCurl("curl -d $'C:\\\\' https://yaak.app;curl https://example.com")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
url: "https://yaak.app",
method: "POST",
bodyType: "application/x-www-form-urlencoded",
body: {
form: [{ name: 'C:\\', value: '', enabled: true }],
form: [{ name: "C:\\", value: "", enabled: true }],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
},
],
}),
baseRequest({ url: 'https://example.com' }),
baseRequest({ url: "https://example.com" }),
],
},
});
});
test('Imports $quoted header with escaped single quotes', () => {
expect(
convertCurl(
`curl https://yaak.app -H $'X-Custom: it\\'s a test'`,
),
).toEqual({
test("Imports $quoted header with escaped single quotes", () => {
expect(convertCurl(`curl https://yaak.app -H $'X-Custom: it\\'s a test'`)).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
headers: [{ name: 'X-Custom', value: "it's a test", enabled: true }],
url: "https://yaak.app",
headers: [{ name: "X-Custom", value: "it's a test", enabled: true }],
}),
],
},
});
});
test('Does not split on escaped semicolon outside quotes', () => {
test("Does not split on escaped semicolon outside quotes", () => {
// In shell, \; is a literal semicolon and should not split commands.
// This should be treated as a single curl command with the URL "https://yaak.app?a=1;b=2"
expect(
convertCurl('curl https://yaak.app?a=1\\;b=2'),
).toEqual({
expect(convertCurl("curl https://yaak.app?a=1\\;b=2")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
urlParameters: [
{ name: 'a', value: '1;b=2', enabled: true },
],
url: "https://yaak.app",
urlParameters: [{ name: "a", value: "1;b=2", enabled: true }],
}),
],
},
});
});
test('Imports multipart form data with text-only fields from --data-raw', () => {
test("Imports multipart form data with text-only fields from --data-raw", () => {
const curlCommand = `curl 'http://example.com/api' \
-H 'Content-Type: multipart/form-data; boundary=----FormBoundary123' \
--data-raw $'------FormBoundary123\r\nContent-Disposition: form-data; name="field1"\r\n\r\nvalue1\r\n------FormBoundary123\r\nContent-Disposition: form-data; name="field2"\r\n\r\nvalue2\r\n------FormBoundary123--\r\n'`;
@@ -629,20 +607,20 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'http://example.com/api',
method: 'POST',
url: "http://example.com/api",
method: "POST",
headers: [
{
name: 'Content-Type',
value: 'multipart/form-data; boundary=----FormBoundary123',
name: "Content-Type",
value: "multipart/form-data; boundary=----FormBoundary123",
enabled: true,
},
],
bodyType: 'multipart/form-data',
bodyType: "multipart/form-data",
body: {
form: [
{ name: 'field1', value: 'value1', enabled: true },
{ name: 'field2', value: 'value2', enabled: true },
{ name: "field1", value: "value1", enabled: true },
{ name: "field2", value: "value2", enabled: true },
],
},
}),
@@ -658,17 +636,17 @@ function baseRequest(mergeWith: Partial<HttpRequest>) {
idCount.http_request = (idCount.http_request ?? -1) + 1;
return {
id: `GENERATE_ID::HTTP_REQUEST_${idCount.http_request}`,
model: 'http_request',
model: "http_request",
authentication: {},
authenticationType: null,
body: {},
bodyType: null,
folderId: null,
headers: [],
method: 'GET',
name: '',
method: "GET",
name: "",
sortPriority: 0,
url: '',
url: "",
urlParameters: [],
workspaceId: `GENERATE_ID::WORKSPACE_${idCount.workspace}`,
...mergeWith,
@@ -679,8 +657,8 @@ function baseWorkspace(mergeWith: Partial<Workspace> = {}) {
idCount.workspace = (idCount.workspace ?? -1) + 1;
return {
id: `GENERATE_ID::WORKSPACE_${idCount.workspace}`,
model: 'workspace',
name: 'Curl Import',
model: "workspace",
name: "Curl Import",
...mergeWith,
};
}