mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Convert the remaining Insomnia tags to native Yaak functions (#712)
This commit is contained in:
1 parent
411aa262c7
commit
0a57d8eb61
2 files changed
+294
-10
No files matched your search
@@ -24,13 +24,13 @@ function decodeArgument(value: string): string | null {
|
||||
function parseArgs(input: string): string[] | null {
|
||||
const args: string[] = [];
|
||||
const token =
|
||||
/\s*(?:'((?:[^'\\]|\\['"\\nrt])*)'|"((?:[^"\\]|\\['"\\nrt])*)"|(-?\d+(?:\.\d+)?))\s*/y;
|
||||
/\s*(?:'((?:[^'\\]|\\['"\\nrt])*)'|"((?:[^"\\]|\\['"\\nrt])*)"|(-?\d+(?:\.\d+)?)|(true|false))\s*/y;
|
||||
let offset = 0;
|
||||
while (offset < input.length) {
|
||||
token.lastIndex = offset;
|
||||
const match = token.exec(input);
|
||||
if (!match) return input.trim() === "" ? [] : null;
|
||||
const literal = (match[1] ?? match[2] ?? match[3]!).replace(
|
||||
const literal = (match[1] ?? match[2] ?? match[3] ?? match[4]!).replace(
|
||||
/\\(['"\\nrt])/g,
|
||||
(_, char: string) => ({ n: "\n", r: "\r", t: "\t" })[char] ?? char,
|
||||
);
|
||||
@@ -46,16 +46,129 @@ function parseArgs(input: string): string[] | null {
|
||||
return args;
|
||||
}
|
||||
|
||||
function convertTag(name: string, input: string, requestIds: Set<string>): string | null {
|
||||
const args = parseArgs(input);
|
||||
if (args == null) return null;
|
||||
if (name === "uuid" && args.length === 1 && ["v1", "v4"].includes(args[0]!)) {
|
||||
return `uuid.${args[0]}()`;
|
||||
/**
|
||||
* Insomnia's dropdown arguments fall back to the first option of their list when a tag leaves
|
||||
* them out, so every converter below mirrors that first option as its default.
|
||||
*/
|
||||
|
||||
function convertUuid(args: string[]): string | null {
|
||||
// Version 4 is listed first, so a bare tag generates one.
|
||||
if (args.length === 0) return "uuid.v4()";
|
||||
if (args.length === 1 && ["v1", "v4"].includes(args[0]!)) return `uuid.${args[0]}()`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function convertNow(args: string[]): string | null {
|
||||
if (args.length > 2) return null;
|
||||
const [kind = "iso-8601", format = ""] = args;
|
||||
switch (kind.toLowerCase()) {
|
||||
case "iso-8601":
|
||||
return "timestamp.iso8601()";
|
||||
case "millis":
|
||||
case "ms":
|
||||
return "timestamp.unixMillis()";
|
||||
case "unix":
|
||||
case "seconds":
|
||||
case "s":
|
||||
return "timestamp.unix()";
|
||||
case "custom":
|
||||
// The format string is passed through untranslated. Insomnia formats with date-fns
|
||||
// today, like Yaak does, but older versions used Moment and those token sets differ
|
||||
// (eg. Moment's DD/YYYY against date-fns' dd/yyyy). A token that no longer means the
|
||||
// same thing shows up as a tag the user can correct.
|
||||
return `timestamp.format(format=${argument(format)})`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
if (name === "faker" && args.length === 1 && args[0] === "randomUUID") {
|
||||
return "uuid.v4()";
|
||||
}
|
||||
|
||||
/** The legacy timestamp tag takes no arguments and returns milliseconds. */
|
||||
function convertTimestamp(args: string[]): string | null {
|
||||
return args.length === 0 ? "timestamp.unixMillis()" : null;
|
||||
}
|
||||
|
||||
const base64Encodings: Record<string, string> = {
|
||||
normal: "base64",
|
||||
url: "base64url",
|
||||
// Insomnia's hex mode reads/writes hex rather than text. Yaak has no such mode, so this
|
||||
// falls back to plain base64, which at least imports as a tag instead of raw text.
|
||||
hex: "base64",
|
||||
};
|
||||
|
||||
function convertBase64(args: string[]): string | null {
|
||||
if (args.length < 2 || args.length > 3) return null;
|
||||
// Assumed: exports written before the Kind argument existed pass (action, value), so a
|
||||
// lone second argument that names no kind is the value.
|
||||
const legacy = args.length === 2 && !Object.hasOwn(base64Encodings, args[1]!);
|
||||
const encoding = base64Encodings[legacy ? "normal" : args[1]!];
|
||||
const value = legacy ? args[1]! : (args[2] ?? "");
|
||||
if (encoding == null) return null;
|
||||
if (args[0] === "encode") {
|
||||
return `base64.encode(encoding='${encoding}', value=${argument(value)})`;
|
||||
}
|
||||
if (name !== "response" || args.length < 3 || args.length > 5) return null;
|
||||
// Yaak's decoder reads both alphabets, so the kind doesn't change the call.
|
||||
if (args[0] === "decode") return `base64.decode(value=${argument(value)})`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashAlgorithms = ["md5", "sha1", "sha256", "sha512"];
|
||||
|
||||
function convertHash(args: string[]): string | null {
|
||||
if (args.length > 3) return null;
|
||||
const [algorithm = "md5", encoding = "hex", value = ""] = args;
|
||||
// Insomnia's own tag falls back to SHA-256 for algorithms outside its list.
|
||||
const name = hashAlgorithms.includes(algorithm.toLowerCase())
|
||||
? algorithm.toLowerCase()
|
||||
: "sha256";
|
||||
// Insomnia offers only hex and base64, the same two digests Yaak does.
|
||||
const digest = encoding === "base64" ? "base64" : "hex";
|
||||
return `hash.${name}(input=${argument(value)}, encoding='${digest}')`;
|
||||
}
|
||||
|
||||
function convertCookie(args: string[]): string | null {
|
||||
if (args.length !== 2) return null;
|
||||
const [url = "", name = ""] = args;
|
||||
if (name === "") return null;
|
||||
const domain = cookieDomain(url);
|
||||
const filter = domain === "" ? "" : `, domain=${argument(domain)}`;
|
||||
return `cookie.value(name=${argument(name)}${filter})`;
|
||||
}
|
||||
|
||||
/** Insomnia matches cookies against a full URL, while Yaak filters the jar by domain. */
|
||||
function cookieDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
// Not a URL we can read, usually because it holds a template tag. Keep the text so the
|
||||
// user can see and fix it rather than dropping the filter.
|
||||
return url.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function convertPrompt(args: string[]): string | null {
|
||||
if (args.length > 6) return null;
|
||||
const [title = "", label = "", defaultValue = "", storageKey = "", mask = "false"] = args;
|
||||
// Insomnia requires a title and shows the label above the input, falling back to the title.
|
||||
if (title === "" && label === "") return null;
|
||||
const parts = [`label=${argument(label === "" ? title : label)}`];
|
||||
const masked = mask === "true";
|
||||
if (storageKey !== "" && !masked) {
|
||||
// Insomnia keeps a stored value only until the app closes. Yaak's nearest option is to
|
||||
// store it forever, which is close enough for plain values. Masked values are left
|
||||
// unstored, which is Yaak's default, so a password never outlives the session it was
|
||||
// typed in. Storing needs a namespace, and the workspace is what Yaak's editor defaults to.
|
||||
// oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax
|
||||
const namespace = argument("${[ctx.workspace()]}");
|
||||
parts.push("store='forever'", `namespace=${namespace}`, `key=${argument(storageKey)}`);
|
||||
}
|
||||
if (title !== "") parts.push(`title=${argument(title)}`);
|
||||
if (defaultValue !== "") parts.push(`defaultValue=${argument(defaultValue)}`);
|
||||
if (masked) parts.push("password=true");
|
||||
return `prompt.text(${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
function convertResponse(args: string[], requestIds: Set<string>): string | null {
|
||||
if (args.length < 2 || args.length > 5) return null;
|
||||
const [attribute, request, filter = "", behavior = "never", maxAge = "0"] = args;
|
||||
if (!request || !requestIds.has(convertId(request))) return null;
|
||||
const behaviors: Record<string, string> = {
|
||||
@@ -81,9 +194,37 @@ function convertTag(name: string, input: string, requestIds: Set<string>): strin
|
||||
if (attribute === "header" && filter !== "") {
|
||||
return `response.header(${common}, header=${argument(filter.trim())})`;
|
||||
}
|
||||
// Insomnia's url attribute has no counterpart, since Yaak has no response URL function.
|
||||
return null;
|
||||
}
|
||||
|
||||
function convertTag(name: string, input: string, requestIds: Set<string>): string | null {
|
||||
const args = parseArgs(input);
|
||||
if (args == null) return null;
|
||||
switch (name) {
|
||||
case "base64":
|
||||
return convertBase64(args);
|
||||
case "cookie":
|
||||
return convertCookie(args);
|
||||
case "faker":
|
||||
return args.length === 1 && args[0] === "randomUUID" ? "uuid.v4()" : null;
|
||||
case "hash":
|
||||
return convertHash(args);
|
||||
case "now":
|
||||
return convertNow(args);
|
||||
case "prompt":
|
||||
return convertPrompt(args);
|
||||
case "response":
|
||||
return convertResponse(args, requestIds);
|
||||
case "timestamp":
|
||||
return convertTimestamp(args);
|
||||
case "uuid":
|
||||
return convertUuid(args);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function convertTemplateSyntax<T>(obj: T, requestIds = new Set<string>()): T {
|
||||
if (typeof obj === "string") {
|
||||
return obj
|
||||
|
||||
@@ -32,6 +32,17 @@ describe("Insomnia templates", () => {
|
||||
"{% response 'body', 'req_login', '/root/token', 'always', 60 %}",
|
||||
"{% response 'body', 'req_login', '', 'never', 0 %}",
|
||||
"{% response 'body', 'req_login', '$.token', 'when-expired', 0 %}",
|
||||
"{% response 'url', 'req_login', '', 'never', 0 %}",
|
||||
"{% os 'arch' %}",
|
||||
"{% file '/tmp/token.txt' %}",
|
||||
"{% jsonpath '{}', '$.a' %}",
|
||||
"{% request 'url' %}",
|
||||
"{% now 'quarter' %}",
|
||||
"{% uuid 'v4', 'extra' %}",
|
||||
"{% base64 'encode' %}",
|
||||
"{% cookie 'https://example.com' %}",
|
||||
"{% prompt %}",
|
||||
"{% timestamp 'ms' %}",
|
||||
])("preserves unsupported or malformed syntax: %s", (input) => {
|
||||
expect(convert(input)).toBe(input);
|
||||
});
|
||||
@@ -74,6 +85,138 @@ describe("Insomnia templates", () => {
|
||||
).toContain(`header=${encoded("x-'quoted,header")}`);
|
||||
});
|
||||
|
||||
test("converts a bare UUID tag to v4, the version Insomnia lists first", () => {
|
||||
expect(convert("{% uuid %}")).toBe("${[ uuid.v4() ]}");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["{% now %}", "timestamp.iso8601()"],
|
||||
["{% now 'iso-8601' %}", "timestamp.iso8601()"],
|
||||
["{% now 'ISO-8601' %}", "timestamp.iso8601()"],
|
||||
["{% now 'unix' %}", "timestamp.unix()"],
|
||||
["{% now 'seconds' %}", "timestamp.unix()"],
|
||||
["{% now 's' %}", "timestamp.unix()"],
|
||||
["{% now 'millis' %}", "timestamp.unixMillis()"],
|
||||
["{% now 'ms' %}", "timestamp.unixMillis()"],
|
||||
["{% timestamp %}", "timestamp.unixMillis()"],
|
||||
])("converts %s", (input, output) => {
|
||||
expect(convert(input)).toBe(`\${[ ${output} ]}`);
|
||||
});
|
||||
|
||||
test("passes custom date formats through without translating tokens", () => {
|
||||
const format = "YYYY-MM-DD HH:mm:ss";
|
||||
expect(convert(`{% now 'custom', '${format}' %}`)).toBe(
|
||||
`\${[ timestamp.format(format=${encoded(format)}) ]}`,
|
||||
);
|
||||
expect(convert("{% now 'custom' %}")).toBe(`\${[ timestamp.format(format=${encoded("")}) ]}`);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["{% base64 'encode', 'normal', 'hello' %}", "base64.encode(encoding='base64', value="],
|
||||
["{% base64 'encode', 'url', 'hello' %}", "base64.encode(encoding='base64url', value="],
|
||||
// Yaak has no hex mode, so it imports as the plain alphabet.
|
||||
["{% base64 'encode', 'hex', 'hello' %}", "base64.encode(encoding='base64', value="],
|
||||
["{% base64 'encode', 'hello' %}", "base64.encode(encoding='base64', value="],
|
||||
["{% base64 'decode', 'normal', 'hello' %}", "base64.decode(value="],
|
||||
["{% base64 'decode', 'hello' %}", "base64.decode(value="],
|
||||
])("converts %s", (input, prefix) => {
|
||||
expect(convert(input)).toBe(`\${[ ${prefix}${encoded("hello")}) ]}`);
|
||||
});
|
||||
|
||||
test("defaults an omitted base64 value to the empty string", () => {
|
||||
expect(convert("{% base64 'encode', 'normal' %}")).toBe(
|
||||
`\${[ base64.encode(encoding='base64', value=${encoded("")}) ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("converts variables nested inside tag arguments", () => {
|
||||
expect(convert("{% base64 'encode', 'normal', '{{ _.secret }}' %}")).toBe(
|
||||
`\${[ base64.encode(encoding='base64', value=${encoded("${[ secret ]}")}) ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["md5", "sha1", "sha256", "sha512"])("converts the %s hash", (algorithm) => {
|
||||
expect(convert(`{% hash '${algorithm}', 'hex', 'text' %}`)).toBe(
|
||||
`\${[ hash.${algorithm}(input=${encoded("text")}, encoding='hex') ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the base64 digest encoding", () => {
|
||||
expect(convert("{% hash 'sha256', 'base64', 'text' %}")).toBe(
|
||||
`\${[ hash.sha256(input=${encoded("text")}, encoding='base64') ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("defaults omitted hash arguments to MD5 and hex, like Insomnia's dropdowns", () => {
|
||||
expect(convert("{% hash %}")).toBe(`\${[ hash.md5(input=${encoded("")}, encoding='hex') ]}`);
|
||||
expect(convert("{% hash 'sha1' %}")).toBe(
|
||||
`\${[ hash.sha1(input=${encoded("")}, encoding='hex') ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to SHA-256 for algorithms outside Insomnia's list, as Insomnia does", () => {
|
||||
expect(convert("{% hash 'sha384', 'hex', 'text' %}")).toBe(
|
||||
`\${[ hash.sha256(input=${encoded("text")}, encoding='hex') ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("converts a cookie URL to the domain Yaak filters by", () => {
|
||||
expect(convert("{% cookie 'https://api.example.com/login', 'session' %}")).toBe(
|
||||
`\${[ cookie.value(name=${encoded("session")}, domain=${encoded("api.example.com")}) ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps an unreadable cookie URL so it stays visible in the tag", () => {
|
||||
expect(convert("{% cookie '{{ _.base_url }}', 'session' %}")).toBe(
|
||||
`\${[ cookie.value(name=${encoded("session")}, domain=${encoded("${[ base_url ]}")}) ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("searches the whole jar when the cookie URL is empty", () => {
|
||||
expect(convert("{% cookie '', 'session' %}")).toBe(
|
||||
`\${[ cookie.value(name=${encoded("session")}) ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("converts a prompt with only a title", () => {
|
||||
expect(convert("{% prompt 'Password' %}")).toBe(
|
||||
`\${[ prompt.text(label=${encoded("Password")}, title=${encoded("Password")}) ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("converts every prompt argument with a Yaak counterpart", () => {
|
||||
expect(convert("{% prompt 'Login', 'Username', 'admin', 'user-key', false, true %}")).toBe(
|
||||
`\${[ prompt.text(label=${encoded("Username")}, store='forever', ` +
|
||||
`namespace=${encoded("${[ctx.workspace()]}")}, key=${encoded("user-key")}, ` +
|
||||
`title=${encoded("Login")}, defaultValue=${encoded("admin")}) ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("never stores a masked prompt, even with a storage key", () => {
|
||||
expect(convert("{% prompt 'Login', 'Password', 'hunter2', 'pw-key', true, true %}")).toBe(
|
||||
`\${[ prompt.text(label=${encoded("Password")}, title=${encoded("Login")}, ` +
|
||||
`defaultValue=${encoded("hunter2")}, password=true) ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("masks a prompt without a storage key", () => {
|
||||
expect(convert("{% prompt 'Login', 'Password', '', '', true %}")).toBe(
|
||||
`\${[ prompt.text(label=${encoded("Password")}, title=${encoded("Login")}, password=true) ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves the prompt unmasked and unstored by default", () => {
|
||||
expect(convert("{% prompt 'Login', 'Password', '', '', false, true %}")).toBe(
|
||||
`\${[ prompt.text(label=${encoded("Password")}, title=${encoded("Login")}) ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("converts the two-argument response form", () => {
|
||||
expect(convert("{% response 'raw', 'req_login' %}")).toBe(
|
||||
`\${[ response.body.raw(request=${encoded("GENERATE_ID::req_login")}, behavior='never', ttl='0') ]}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("converts nested resource fields without changing the input", () => {
|
||||
const input = {
|
||||
authentication: { token: "{{ _['token'] }}" },
|
||||
|
||||
Reference in new issue
Block a user