From 34815f327a73e686f8d630a9847720c8291e914e Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 25 Sep 2026 09:58:31 -0700 Subject: [PATCH] Fix Insomnia template and GraphQL import fidelity (#693) --- crates/yaak/src/import.rs | 109 ++++++++++- crates/yaak/src/import_templates.rs | 172 ++++++++++++++++++ crates/yaak/src/lib.rs | 1 + plugins/importer-insomnia/src/common.ts | 155 ++++++++++++++-- plugins/importer-insomnia/src/templates.ts | 113 ++++++++++++ plugins/importer-insomnia/src/v4.ts | 11 +- plugins/importer-insomnia/src/v5.ts | 7 +- plugins/importer-insomnia/tests/body.test.ts | 72 ++++++++ .../tests/fixtures/basic.output.json | 2 +- .../tests/fixtures/chained.input.yaml | 29 +++ .../tests/fixtures/chained.output.json | 85 +++++++++ .../tests/fixtures/version-5.output.json | 6 +- .../importer-insomnia/tests/migration.test.ts | 95 ++++++++++ .../importer-insomnia/tests/templates.test.ts | 88 +++++++++ .../template-function-response/src/index.ts | 11 +- 15 files changed, 921 insertions(+), 35 deletions(-) create mode 100644 crates/yaak/src/import_templates.rs create mode 100644 plugins/importer-insomnia/src/templates.ts create mode 100644 plugins/importer-insomnia/tests/fixtures/chained.input.yaml create mode 100644 plugins/importer-insomnia/tests/fixtures/chained.output.json create mode 100644 plugins/importer-insomnia/tests/migration.test.ts create mode 100644 plugins/importer-insomnia/tests/templates.test.ts diff --git a/crates/yaak/src/import.rs b/crates/yaak/src/import.rs index 9b59756c5..e32f3ee4e 100644 --- a/crates/yaak/src/import.rs +++ b/crates/yaak/src/import.rs @@ -1,4 +1,5 @@ use crate::Result; +use crate::import_templates::remap_request_references; use chrono::Utc; use log::info; use serde_json::Value; @@ -312,11 +313,14 @@ pub fn plan_import_resources( }) .collect(); + let mut request_ids = BTreeMap::new(); let http_requests = resources .http_requests .into_iter() .map(|mut request| { - request.id = HttpRequest::generate_id(); + let new_id = HttpRequest::generate_id(); + request_ids.insert(request.id.clone(), new_id.clone()); + request.id = new_id; request.workspace_id = resolve_workspace_id(&request.workspace_id); request.folder_id = resolve_folder_id(request.folder_id); request @@ -428,7 +432,7 @@ pub fn plan_import_resources( )); } - let resources = BatchUpsertResult { + let mut resources = BatchUpsertResult { workspaces, environments, folders, @@ -436,6 +440,7 @@ pub fn plan_import_resources( grpc_requests, websocket_requests, }; + remap_request_references(&mut resources, &request_ids)?; let mut plan = ImportPlan { importer, @@ -961,6 +966,13 @@ fn merge_with_linked_source( } } + let request_remap = plan + .resources + .http_requests + .iter() + .filter_map(|v| remap.get(&v.id).map(|id| (v.id.clone(), id.clone()))) + .collect(); + remap_request_references(&mut plan.resources, &request_remap)?; let remap_ref = |id: Option| id.map(|v| remap.get(&v).cloned().unwrap_or(v)); for v in &mut plan.resources.folders { if let Some(existing_id) = remap.get(&v.id) { @@ -2600,6 +2612,99 @@ mod tests { ]) } + #[tokio::test] + async fn insomnia_plugin_output_produces_sendable_graphql_and_linked_requests() { + // The JS fixture suite asserts this exact payload against the real importer. + let imported: yaak_plugins::events::ImportResponse = serde_json::from_str(include_str!( + "../../../plugins/importer-insomnia/tests/fixtures/chained.output.json" + )) + .expect("parse plugin output"); + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let plan = plan_import_resources( + &query_manager, + "Insomnia".into(), + ImportDestination::NewWorkspace, + imported.resources, + imported.source_keys, + None, + ) + .expect("plan Insomnia import"); + let committed = commit_import_plan(&query_manager, plan).expect("commit Insomnia import"); + let query = &committed.http_requests[0]; + let login = &committed.http_requests[1]; + assert!(query.authentication["token"].as_str().unwrap().contains(&login.id)); + assert!(!query.authentication["token"].as_str().unwrap().contains("GENERATE_ID")); + let sendable = + yaak_http::types::SendableHttpRequest::from_http_request(query, Default::default()) + .await + .unwrap(); + let Some(yaak_http::types::SendableBody::Bytes(body)) = sendable.body else { + panic!("expected GraphQL JSON body"); + }; + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + json!({ + "query": "query Get($id: ID!) { node(id: $id) { id } }", + "variables": { "id": "123" }, + "operationName": "Get", + }) + ); + assert!( + sendable.headers.iter().any(|(name, value)| name.eq_ignore_ascii_case("content-type") + && value == "application/json") + ); + } + + #[test] + fn request_references_follow_import_and_reimport_ids_without_touching_literals() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let mut resources = imported_resources(); + let template = + "${[ response.body.path(request='rq_root', path='$.token', result='first') ]}"; + resources.http_requests[1].authentication = + BTreeMap::from([("token".into(), json!(template))]); + resources.http_requests[1].body = + BTreeMap::from([("text".into(), json!("rq_root is literal text"))]); + resources.environments[0].variables[0].value = template.into(); + resources.folders[0].authentication = BTreeMap::from([("token".into(), json!(template))]); + resources.workspaces[0].authentication = + BTreeMap::from([("token".into(), json!(template))]); + + let first = first_import_with(&query_manager, resources.clone()); + let workspace_id = &first.workspaces[0].id; + let first_id = &first.http_requests[0].id; + let expected = format!( + "${{[ response.body.path(request='{first_id}', path='$.token', result='first') ]}}" + ); + assert_eq!(first.http_requests[1].authentication["token"], json!(expected)); + assert_eq!(first.environments[0].variables[0].value, expected); + assert_eq!(first.folders[0].authentication["token"], json!(expected)); + assert_eq!(first.workspaces[0].authentication["token"], json!(expected)); + assert_eq!(first.http_requests[1].body["text"], json!("rq_root is literal text")); + + // Reimport allocates temporary IDs, then reuses the previously linked IDs. Neither + // phase may leave a reference behind, or spuriously flag a source/local conflict. + let plan = replan(&query_manager, workspace_id, resources.clone()); + assert_eq!(&plan.resources.http_requests[0].id, first_id); + assert_eq!(plan.resources.http_requests[1].authentication["token"], json!(expected)); + assert!(plan.items.iter().all(|item| item.action == ImportPlanAction::Unchanged)); + commit_import_plan(&query_manager, plan).expect("commit reimport"); + assert_eq!(query_manager.connect().list_http_requests(workspace_id).unwrap().len(), 2); + + // A separate import must point at its own new request, not at the first workspace. + let second = first_import_with(&query_manager, resources); + let second_id = &second.http_requests[0].id; + assert_ne!(first_id, second_id); + assert!( + second.http_requests[1].authentication["token"].as_str().unwrap().contains(second_id) + ); + assert!( + !second.http_requests[1].authentication["token"].as_str().unwrap().contains(first_id) + ); + } + fn first_import(query_manager: &QueryManager) -> BatchUpsertResult { first_import_with(query_manager, imported_resources()) } diff --git a/crates/yaak/src/import_templates.rs b/crates/yaak/src/import_templates.rs new file mode 100644 index 000000000..71a4bf038 --- /dev/null +++ b/crates/yaak/src/import_templates.rs @@ -0,0 +1,172 @@ +use serde_json::Value; +use std::collections::BTreeMap; +use yaak_models::util::BatchUpsertResult; +use yaak_templates::{Parser, Token, Val}; + +/// Rewrite request references at each ID-assignment step (initial import and linked reimport). +/// Literal text, missing targets and arguments to unrelated functions must not be rewritten. +pub(crate) fn remap_request_references( + resources: &mut BatchUpsertResult, + ids: &BTreeMap, +) -> serde_json::Result<()> { + if ids.is_empty() { + return Ok(()); + } + let mut value = serde_json::to_value(&resources)?; + remap_value(&mut value, ids); + *resources = serde_json::from_value(value)?; + Ok(()) +} + +fn remap_value(value: &mut Value, ids: &BTreeMap) { + match value { + Value::String(text) => *text = remap_text(text, ids), + Value::Array(values) => values.iter_mut().for_each(|v| remap_value(v, ids)), + Value::Object(values) => values.values_mut().for_each(|v| remap_value(v, ids)), + _ => {} + } +} + +fn remap_argument(value: &mut Val, ids: &BTreeMap) -> bool { + let Val::Fn { name, args } = value else { + return false; + }; + let request_arg = match name.as_str() { + "response" | "response.body.raw" | "response.body.path" | "response.header" => "request", + "request.body" | "request.body.raw" | "request.body.path" | "request.header" + | "request.param" | "request.name" => "requestId", + _ => "", + }; + let mut changed = false; + for arg in args { + if arg.name == request_arg + && let Val::Str { text } = &mut arg.value + && let Some(id) = ids.get(text) + && id != text + { + *text = id.clone(); + changed = true; + } + changed |= remap_argument(&mut arg.value, ids); + } + changed +} + +fn remap_text(text: &str, ids: &BTreeMap) -> String { + // Parse individual unescaped tags. Parsing/reprinting the whole string would unescape raw + // template-looking text, and change formatting even when no reference needs updating. + let bytes = text.as_bytes(); + let mut output = String::new(); + let mut copied = 0; + let mut cursor = 0; + while let Some(relative) = text[cursor..].find("${[") { + let start = cursor + relative; + cursor = start + 3; + let escapes = bytes[..start].iter().rev().take_while(|&&b| b == b'\\').count(); + if escapes % 2 != 0 { + continue; + } + let mut end = cursor; + let mut quoted = false; + while end < bytes.len() { + if quoted && bytes[end] == b'\\' { + end += 2; + continue; + } + if bytes[end] == b'\'' { + quoted = !quoted; + } + if !quoted && bytes[end..].starts_with(b"]}") { + end += 2; + break; + } + end += 1; + } + if end > bytes.len() || !text[start..end].ends_with("]}") { + continue; + } + if let Ok(mut tokens) = Parser::new(&text[start..end]).parse() + && let [Token::Tag { val }, Token::Eof] = tokens.tokens.as_mut_slice() + && remap_argument(val, ids) + { + output.push_str(&text[copied..start]); + output.push_str(&tokens.to_string()); + copied = end; + } + cursor = end; + } + output.push_str(&text[copied..]); + output +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids() -> BTreeMap { + BTreeMap::from([("req_old".into(), "rq_new".into())]) + } + + #[test] + fn only_remaps_known_literal_request_arguments() { + let text = "${[ response.header(request='req_old', header='req_old') ]}"; + assert_eq!( + remap_text(text, &ids()), + "${[ response.header(request='rq_new', header='req_old') ]}" + ); + for text in [ + "req_old", + "${[ custom(request='req_old') ]}", + "${[ response.header(request=req_old) ]}", + "${[ response.header(request='req_missing') ]}", + "${[ response.header(request='req_old' ]}", + ] { + assert_eq!(remap_text(text, &ids()), text); + } + } + + #[test] + fn handles_encoded_nested_and_escaped_tags_without_changing_surrounding_text() { + let text = r#"café \${[ response.header(request='req_old') ]} ${[ json.jsonpath(input=response.body.raw(request=b64'cmVxX29sZA')) ]} end"#; + assert_eq!( + remap_text(text, &ids()), + r#"café \${[ response.header(request='req_old') ]} ${[ json.jsonpath(input=response.body.raw(request='rq_new')) ]} end"# + ); + let text = "${[ response.header(request='req_old', header=']}' ) ]}"; + assert!(remap_text(text, &ids()).contains("request='rq_new'")); + } + + /// Only the request ID may change. Every other argument keeps its name, order, spacing and + /// plain single-quoted form, so a printer that falls back to base64 for values like + /// `X-Api-Key` or `$.token` fails here. + #[test] + fn remapping_a_request_keeps_the_other_arguments_verbatim() { + assert_eq!( + remap_text("${[ response.header(request='req_old', header='X-Api-Key') ]}", &ids()), + "${[ response.header(request='rq_new', header='X-Api-Key') ]}" + ); + assert_eq!( + remap_text( + "${[ response.body.path(request='req_old', path='$.token', behavior='never') ]}", + &ids() + ), + "${[ response.body.path(request='rq_new', path='$.token', behavior='never') ]}" + ); + } + + /// An Insomnia tag that never got converted is plain text, even though it names a request + /// that is being remapped. It must survive byte-for-byte, including alongside a real tag. + #[test] + fn unconverted_nunjucks_text_containing_a_request_id_is_untouched() { + let text = "{% response 'raw', 'req_old', '', 'never', 60 %}"; + assert_eq!(remap_text(text, &ids()), text); + + assert_eq!( + remap_text( + "${[ response.body.path(request='req_old', path='$.a') ]} and {% response 'raw', 'req_old' %}", + &ids() + ), + "${[ response.body.path(request='rq_new', path='$.a') ]} and {% response 'raw', 'req_old' %}" + ); + } +} diff --git a/crates/yaak/src/lib.rs b/crates/yaak/src/lib.rs index 6d8aedfab..6a7ca9552 100644 --- a/crates/yaak/src/lib.rs +++ b/crates/yaak/src/lib.rs @@ -1,5 +1,6 @@ pub mod error; pub mod import; +mod import_templates; pub mod plugin_events; pub mod response_body; pub mod send; diff --git a/plugins/importer-insomnia/src/common.ts b/plugins/importer-insomnia/src/common.ts index 4d2d2708f..67575f68a 100644 --- a/plugins/importer-insomnia/src/common.ts +++ b/plugins/importer-insomnia/src/common.ts @@ -34,13 +34,15 @@ export function importHttpBodyAndHeaders(obj: any) { const { headers } = importHeaders(obj); const { body, bodyType } = importHttpBody(obj.body); const mimeType = typeof obj.body?.mimeType === "string" ? obj.body.mimeType.trim() : ""; + // Insomnia uses application/graphql as an editor marker, but sends a JSON envelope. + const contentType = bodyType === "graphql" ? "application/json" : mimeType; if ( bodyType != null && mimeType !== "" && !headers.some((header: { name: string }) => header.name.toLowerCase() === "content-type") ) { - headers.push({ enabled: true, name: "Content-Type", value: mimeType }); + headers.push({ enabled: true, name: "Content-Type", value: contentType }); } return { body, bodyType, headers }; @@ -93,7 +95,36 @@ function importHttpBody(rawBody: any) { } if (normalizedMimeType === "application/graphql") { - return { bodyType: "graphql", body: { text: rawBody.text ?? "" } }; + const text = typeof rawBody.text === "string" ? rawBody.text : ""; + try { + const parsed = JSON.parse(text); + if (typeof parsed?.query === "string") { + return { + bodyType: "graphql", + body: { + query: parsed.query, + variables: + typeof parsed.variables === "string" + ? parsed.variables + : parsed.variables == null + ? "" + : JSON.stringify(parsed.variables, null, 2), + ...(typeof parsed.operationName === "string" + ? { operationName: parsed.operationName } + : {}), + }, + }; + } + } catch { + // A raw GraphQL document is also valid; preserve it verbatim. An envelope that + // failed to parse is usually one with template tags in it, so recover its parts + // rather than dropping the whole envelope into the query editor. + const recovered = recoverGraphQLEnvelope(text); + if (recovered != null) { + return { bodyType: "graphql", body: recovered }; + } + } + return { bodyType: "graphql", body: { query: text, variables: "" } }; } if (normalizedMimeType === "application/json" || normalizedMimeType.endsWith("+json")) { @@ -115,6 +146,109 @@ function importHttpBody(rawBody: any) { return { bodyType: null, body: {} }; } +/** + * Pull the parts out of a GraphQL envelope that isn't valid JSON. + * + * Insomnia lets template tags be written straight into the envelope + * ({"query":"{me{id}}","variables":{"id":{{ _.id }}}}), which makes the text + * unparseable. Members are read as raw text so the tags survive the trip. + */ +function recoverGraphQLEnvelope( + text: string, +): { query: string; variables: string; operationName?: string } | null { + const members = readTopLevelMembers(text); + if (members == null) return null; + + const query = parseJSONString(members.get("query")); + if (query == null) return null; + + const variables = members.get("variables") ?? ""; + const operationName = parseJSONString(members.get("operationName")); + return { + query, + variables: variables === "null" ? "" : variables, + ...(operationName == null ? {} : { operationName }), + }; +} + +function parseJSONString(raw: string | undefined): string | null { + if (raw == null || !raw.startsWith('"')) return null; + try { + const parsed = JSON.parse(raw); + return typeof parsed === "string" ? parsed : null; + } catch { + return null; + } +} + +/** + * The raw text of each member of the outermost object, without parsing the values. + * Returns null unless the whole object is well-formed enough to walk. + */ +function readTopLevelMembers(text: string): Map | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("{")) return null; + + const members = new Map(); + let offset = 1; + while (offset < trimmed.length) { + while (/[\s,]/.test(trimmed[offset] ?? "")) offset++; + if (trimmed[offset] === "}") return members; + + const keyEnd = scanJSONString(trimmed, offset); + if (keyEnd == null) return null; + const key = parseJSONString(trimmed.slice(offset, keyEnd)); + if (key == null) return null; + + offset = keyEnd; + while (/\s/.test(trimmed[offset] ?? "")) offset++; + if (trimmed[offset] !== ":") return null; + offset++; + while (/\s/.test(trimmed[offset] ?? "")) offset++; + + const valueEnd = scanJSONValue(trimmed, offset); + if (valueEnd == null) return null; + members.set(key, trimmed.slice(offset, valueEnd).trim()); + offset = valueEnd; + } + return null; +} + +/** Offset just past the string starting at `start`, or null if it never closes. */ +function scanJSONString(text: string, start: number): number | null { + if (text[start] !== '"') return null; + for (let i = start + 1; i < text.length; i++) { + if (text[i] === "\\") i++; + else if (text[i] === '"') return i + 1; + } + return null; +} + +/** + * Offset just past the value starting at `start`. Nesting is tracked so template + * tags come back whole, but the value itself is not validated. + */ +function scanJSONValue(text: string, start: number): number | null { + let depth = 0; + for (let i = start; i < text.length; i++) { + const char = text[i]!; + if (char === '"') { + const end = scanJSONString(text, i); + if (end == null) return null; + i = end - 1; + } else if (char === "{" || char === "[") { + depth++; + } else if (char === "}" || char === "]") { + if (depth === 0) return i; // The member ended with the enclosing object + depth--; + if (depth === 0) return i + 1; + } else if (depth === 0 && char === ",") { + return i; + } + } + return null; +} + export function deleteUndefinedAttrs(obj: T): T { if (Array.isArray(obj) && obj != null) { return obj.map(deleteUndefinedAttrs) as T; @@ -128,20 +262,3 @@ export function deleteUndefinedAttrs(obj: T): T { } return obj; } - -/** Recursively render all nested object properties */ -export function convertTemplateSyntax(obj: T): T { - if (typeof obj === "string") { - // oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax - return obj.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") as T; - } - if (Array.isArray(obj) && obj != null) { - return obj.map(convertTemplateSyntax) as T; - } - if (typeof obj === "object" && obj != null) { - return Object.fromEntries( - Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]), - ) as T; - } - return obj; -} diff --git a/plugins/importer-insomnia/src/templates.ts b/plugins/importer-insomnia/src/templates.ts new file mode 100644 index 000000000..c8241623e --- /dev/null +++ b/plugins/importer-insomnia/src/templates.ts @@ -0,0 +1,113 @@ +import { Buffer } from "node:buffer"; +import { convertId } from "./common"; + +// Convert only syntax with a native equivalent. Leave filters, expressions and third-party +// tags intact rather than importing something that looks valid but means something different. +const identifier = /^[\p{L}\p{N}_][\p{L}\p{N}_.-]*$/u; + +function argument(value: string): string { + return `b64'${Buffer.from(value).toString("base64url")}'`; +} + +function decodeArgument(value: string): string | null { + if (!value.startsWith("b64::")) return value; + const match = /^b64::([A-Za-z0-9+/_-]*={0,2})(?:::[^:]*)?$/.exec(value); + if (!match) return null; + const bytes = Buffer.from(match[1]!, "base64"); + const normalized = match[1]!.replace(/=+$/, "").replaceAll("+", "-").replaceAll("/", "_"); + if (bytes.toString("base64url") !== normalized) return null; + const decoded = bytes.toString("utf8"); + return Buffer.from(decoded).equals(bytes) ? decoded : null; +} + +/** Literal arguments only; never evaluate Nunjucks or silently skip unexpected tokens. */ +function parseArgs(input: string): string[] | null { + const args: string[] = []; + const token = + /\s*(?:'((?:[^'\\]|\\['"\\nrt])*)'|"((?:[^"\\]|\\['"\\nrt])*)"|(-?\d+(?:\.\d+)?))\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( + /\\(['"\\nrt])/g, + (_, char: string) => ({ n: "\n", r: "\r", t: "\t" })[char] ?? char, + ); + const decoded = decodeArgument(literal); + if (decoded == null) return null; + args.push(decoded); + offset = token.lastIndex; + if (offset === input.length) break; + if (input[offset] !== ",") return null; + offset++; + if (input.slice(offset).trim() === "") return null; + } + return args; +} + +function convertTag(name: string, input: string, requestIds: Set): 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]}()`; + } + if (name === "faker" && args.length === 1 && args[0] === "randomUUID") { + return "uuid.v4()"; + } + if (name !== "response" || args.length < 3 || 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 = { + never: "never", + "no-history": "smart", + always: "always", + "when-expired": "ttl", + }; + const convertedBehavior = Object.hasOwn(behaviors, behavior) ? behaviors[behavior] : null; + if (!convertedBehavior || !/^\d+$/.test(maxAge)) return null; + // Insomnia treats zero as immediately expired; Yaak uses zero for never expiring. + if (convertedBehavior === "ttl" && Number(maxAge) === 0) return null; + const common = `request=${argument(convertId(request))}, behavior='${convertedBehavior}', ttl='${maxAge}'`; + if (attribute === "raw") { + return `response.body.raw(${common})`; + } + if (attribute === "body") { + // Insomnia returns a scalar for one match, but an array for several. Yaak's first/all + // modes cannot express that automatically. Only convert definite paths for now. + if (!/^\$(?:\.[\p{L}\p{N}_-]+|\[(?:0|[1-9]\d*)\])*$/u.test(filter.trim())) return null; + return `response.body.path(${common}, path=${argument(filter.trim())}, result='first')`; + } + if (attribute === "header" && filter !== "") { + return `response.header(${common}, header=${argument(filter.trim())})`; + } + return null; +} + +export function convertTemplateSyntax(obj: T, requestIds = new Set()): T { + if (typeof obj === "string") { + return obj + .replace(/{{\s*([\s\S]*?)\s*}}/g, (full, expression: string) => { + const trimmed = expression.trim(); + const bracket = /^_\s*\[\s*(['"])([^'"\\]+)\1\s*\]$/.exec(trimmed); + const name = bracket?.[2] ?? trimmed.replace(/^_\./, ""); + return identifier.test(name) && !["true", "false", "null"].includes(name) + ? `\${[ ${name} ]}` + : full; + }) + .replace( + /{%\s*(\w+)\s*((?:'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|[^'"])*?)%}/g, + (full, name: string, input: string) => { + const converted = convertTag(name, input, requestIds); + return converted == null ? full : `\${[ ${converted} ]}`; + }, + ) as T; + } + if (Array.isArray(obj)) return obj.map((value) => convertTemplateSyntax(value, requestIds)) as T; + if (typeof obj === "object" && obj != null) { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key, convertTemplateSyntax(value, requestIds)]), + ) as T; + } + return obj; +} diff --git a/plugins/importer-insomnia/src/v4.ts b/plugins/importer-insomnia/src/v4.ts index 2f698fdeb..c262d8a29 100644 --- a/plugins/importer-insomnia/src/v4.ts +++ b/plugins/importer-insomnia/src/v4.ts @@ -2,12 +2,12 @@ import type { PartialImportResources } from "@yaakapp/api"; import { convertId, - convertTemplateSyntax, createSourceKeys, importHttpBodyAndHeaders, isJSObject, type SourceKeys, } from "./common"; +import { convertTemplateSyntax } from "./templates"; export function convertInsomniaV4(parsed: any) { if (!Array.isArray(parsed.resources)) return null; @@ -68,7 +68,10 @@ export function convertInsomniaV4(parsed: any) { resources.environments = resources.environments.filter(Boolean); resources.workspaces = resources.workspaces.filter(Boolean); - return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() }; + return { + resources: convertTemplateSyntax(resources, new Set(resources.httpRequests.map((r) => r.id))), + sourceKeys: keys.all(), + }; } function importHttpRequest( @@ -78,12 +81,12 @@ function importHttpRequest( ): PartialImportResources["httpRequests"][0] { let authenticationType: string | null = null; let authentication = {}; - if (r.authentication.type === "bearer") { + if (r.authentication?.type === "bearer") { authenticationType = "bearer"; authentication = { token: r.authentication.token, }; - } else if (r.authentication.type === "basic") { + } else if (r.authentication?.type === "basic") { authenticationType = "basic"; authentication = { username: r.authentication.username, diff --git a/plugins/importer-insomnia/src/v5.ts b/plugins/importer-insomnia/src/v5.ts index e47ca8e26..be267a9cf 100644 --- a/plugins/importer-insomnia/src/v5.ts +++ b/plugins/importer-insomnia/src/v5.ts @@ -2,13 +2,13 @@ import type { PartialImportResources } from "@yaakapp/api"; import { convertId, - convertTemplateSyntax, createSourceKeys, importHeaders, importHttpBodyAndHeaders, isJSObject, type SourceKeys, } from "./common"; +import { convertTemplateSyntax } from "./templates"; export function convertInsomniaV5(parsed: any) { // Assert parsed is object @@ -80,7 +80,10 @@ export function convertInsomniaV5(parsed: any) { resources.environments = resources.environments.filter(Boolean); resources.workspaces = resources.workspaces.filter(Boolean); - return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() }; + return { + resources: convertTemplateSyntax(resources, new Set(resources.httpRequests.map((r) => r.id))), + sourceKeys: keys.all(), + }; } function importHttpRequest( diff --git a/plugins/importer-insomnia/tests/body.test.ts b/plugins/importer-insomnia/tests/body.test.ts index 4b51c9f76..96f17ed84 100644 --- a/plugins/importer-insomnia/tests/body.test.ts +++ b/plugins/importer-insomnia/tests/body.test.ts @@ -2,6 +2,78 @@ import { describe, expect, test } from "vite-plus/test"; import { importHttpBodyAndHeaders } from "../src/common"; describe("importHttpBodyAndHeaders", () => { + test.each([{ id: "123" }, '{"id":"123"}', '{"id":{{ _.id }}}'])( + "imports GraphQL variables into the sendable body shape: %j", + (variables) => { + const result = importHttpBodyAndHeaders({ + body: { + mimeType: "application/graphql", + text: JSON.stringify({ + query: "query Get($id: ID!) { node(id: $id) { id } }", + variables, + operationName: "Get", + }), + }, + }); + expect(result.bodyType).toBe("graphql"); + expect(result.body).toEqual({ + query: "query Get($id: ID!) { node(id: $id) { id } }", + variables: typeof variables === "string" ? variables : JSON.stringify(variables, null, 2), + operationName: "Get", + }); + expect(result.headers).toEqual([ + { name: "Content-Type", value: "application/json", enabled: true }, + ]); + }, + ); + + test.each(["query Get { viewer { id } }", "", "{ malformed"])( + "preserves raw or malformed GraphQL text rather than discarding it: %s", + (query) => { + expect( + importHttpBodyAndHeaders({ body: { mimeType: "application/graphql", text: query } }).body, + ).toEqual({ query, variables: "" }); + }, + ); + + test("preserves GraphQL operation and defaults absent variables", () => { + expect( + importHttpBodyAndHeaders({ + body: { mimeType: "application/graphql; charset=utf-8", text: '{"query":"{ me { id } }"}' }, + }).body, + ).toEqual({ query: "{ me { id } }", variables: "" }); + }); + + test("recovers a GraphQL envelope holding template tags that break JSON parsing", () => { + expect( + importHttpBodyAndHeaders({ + body: { + mimeType: "application/graphql", + text: '{"query":"{me{id}}","operationName":"Get","variables":{"id":{{ _.id }}}}', + }, + }).body, + ).toEqual({ + query: "{me{id}}", + variables: '{"id":{{ _.id }}}', + operationName: "Get", + }); + }); + + test("defaults variables to empty when an unparseable envelope has none", () => { + expect( + importHttpBodyAndHeaders({ + body: { mimeType: "application/graphql", text: '{"query":"{me{id}}","meta":{{ _.meta }}}' }, + }).body, + ).toEqual({ query: "{me{id}}", variables: "" }); + }); + + test("keeps an envelope-looking document in the query when no query member is readable", () => { + const text = "{ query { me { id } } }"; + expect( + importHttpBodyAndHeaders({ body: { mimeType: "application/graphql", text } }).body, + ).toEqual({ query: text, variables: "" }); + }); + test("imports XML text using the native XML body type", () => { const result = importHttpBodyAndHeaders({ body: { diff --git a/plugins/importer-insomnia/tests/fixtures/basic.output.json b/plugins/importer-insomnia/tests/fixtures/basic.output.json index b8705d6f9..927dbc8a3 100644 --- a/plugins/importer-insomnia/tests/fixtures/basic.output.json +++ b/plugins/importer-insomnia/tests/fixtures/basic.output.json @@ -112,7 +112,7 @@ "method": "GET", "model": "http_request", "name": "New Request", - "url": "${[BASE_URL ]}/foo/:id", + "url": "${[ BASE_URL ]}/foo/:id", "urlParameters": [ { "name": "query", diff --git a/plugins/importer-insomnia/tests/fixtures/chained.input.yaml b/plugins/importer-insomnia/tests/fixtures/chained.input.yaml new file mode 100644 index 000000000..8a7d1eeb8 --- /dev/null +++ b/plugins/importer-insomnia/tests/fixtures/chained.input.yaml @@ -0,0 +1,29 @@ +# Synthetic regression based on the migration cases described by +# https://github.com/w33ble/insomnia-yaak-converter (no customer data). +type: collection.insomnia.rest/5.0 +name: Chained GraphQL +meta: + id: wrk_chained +environments: + name: Base + meta: + id: env_base + data: + host: https://example.com +collection: + - name: GraphQL + meta: + id: req_query + method: POST + url: "{{ _['host'] }}/graphql" + authentication: + type: bearer + token: "{% response 'body', 'req_login', 'b64::JC50b2tlbg==::nonce', 'when-expired', 60 %}" + body: + mimeType: application/graphql + text: '{"query":"query Get($id: ID!) { node(id: $id) { id } }","variables":"{\"id\":\"123\"}","operationName":"Get"}' + - name: Login + meta: + id: req_login + method: POST + url: "{{ _.host }}/login" diff --git a/plugins/importer-insomnia/tests/fixtures/chained.output.json b/plugins/importer-insomnia/tests/fixtures/chained.output.json new file mode 100644 index 000000000..a99e385a1 --- /dev/null +++ b/plugins/importer-insomnia/tests/fixtures/chained.output.json @@ -0,0 +1,85 @@ +{ + "resources": { + "environments": [ + { + "id": "GENERATE_ID::env_base", + "workspaceId": "GENERATE_ID::wrk_chained", + "public": true, + "parentModel": "workspace", + "parentId": null, + "model": "environment", + "name": "Base", + "variables": [ + { + "enabled": true, + "name": "host", + "value": "https://example.com" + } + ] + } + ], + "folders": [], + "grpcRequests": [], + "httpRequests": [ + { + "id": "GENERATE_ID::req_query", + "workspaceId": "GENERATE_ID::wrk_chained", + "folderId": null, + "model": "http_request", + "name": "GraphQL", + "url": "${[ host ]}/graphql", + "urlParameters": [], + "body": { + "query": "query Get($id: ID!) { node(id: $id) { id } }", + "variables": "{\"id\":\"123\"}", + "operationName": "Get" + }, + "bodyType": "graphql", + "headers": [ + { + "enabled": true, + "name": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "authenticationType": "bearer", + "authentication": { + "token": "${[ response.body.path(request=b64'R0VORVJBVEVfSUQ6OnJlcV9sb2dpbg', behavior='ttl', ttl='60', path=b64'JC50b2tlbg', result='first') ]}" + } + }, + { + "id": "GENERATE_ID::req_login", + "workspaceId": "GENERATE_ID::wrk_chained", + "folderId": null, + "model": "http_request", + "name": "Login", + "url": "${[ host ]}/login", + "urlParameters": [], + "body": {}, + "bodyType": null, + "headers": [], + "method": "POST", + "authenticationType": null, + "authentication": {} + } + ], + "websocketRequests": [], + "workspaces": [ + { + "id": "GENERATE_ID::wrk_chained", + "model": "workspace", + "name": "Chained GraphQL", + "headers": [], + "authenticationType": null, + "authentication": {} + } + ] + }, + "sourceKeys": { + "GENERATE_ID::wrk_chained": "wrk_chained", + "GENERATE_ID::env_base": "env_base", + "GENERATE_ID::req_query": "req_query", + "GENERATE_ID::req_login": "req_login" + } +} diff --git a/plugins/importer-insomnia/tests/fixtures/version-5.output.json b/plugins/importer-insomnia/tests/fixtures/version-5.output.json index 1cff2cee6..685c03aa4 100644 --- a/plugins/importer-insomnia/tests/fixtures/version-5.output.json +++ b/plugins/importer-insomnia/tests/fixtures/version-5.output.json @@ -130,8 +130,8 @@ }, { "enabled": true, - "name": "${[ApiHeaderName ]}", - "value": "${[ApiKey ]}" + "name": "${[ ApiHeaderName ]}", + "value": "${[ ApiKey ]}" } ], "id": "GENERATE_ID::req_d72fff2a6b104b91a2ebe9de9edd2785", @@ -139,7 +139,7 @@ "model": "http_request", "name": "New Request", "sortPriority": -1736781406672, - "url": "${[BASE_URL ]}/foo/:id", + "url": "${[ BASE_URL ]}/foo/:id", "urlParameters": [ { "name": "query", diff --git a/plugins/importer-insomnia/tests/migration.test.ts b/plugins/importer-insomnia/tests/migration.test.ts new file mode 100644 index 000000000..98322a8bb --- /dev/null +++ b/plugins/importer-insomnia/tests/migration.test.ts @@ -0,0 +1,95 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, test } from "vite-plus/test"; +import YAML from "yaml"; +import { convertInsomnia } from "../src"; + +describe.each([4, 5])("Insomnia v%i migration", (version) => { + test("imports GraphQL, credentials and forward response references together", () => { + const requests = [ + { + _id: "req_query", + meta: { id: "req_query" }, + _type: "request", + parentId: "wrk_test", + name: "Query", + method: "POST", + url: "{{ _['host'] }}/graphql", + authentication: { + type: "bearer", + token: "{% response 'body', 'req_login', 'b64::JC50b2tlbg==::abc', 'when-expired', 60 %}", + }, + headers: [{ name: "X-Request-ID", value: "{% uuid 'v4' %}" }], + parameters: [{ name: "id", value: '{{ _["id"] }}' }], + body: { + mimeType: "application/graphql", + text: JSON.stringify({ + query: "query Get($id: ID!) { node(id: $id) { id } }", + variables: '{"id":"{{ _.id }}"}', + operationName: "Get", + }), + }, + }, + { + _id: "req_login", + meta: { id: "req_login" }, + _type: "request", + parentId: "wrk_test", + name: "Login", + method: "POST", + url: "{{ _.host }}/login", + // Missing authentication is normal in minimal exports. + }, + ]; + const source = + version === 5 + ? YAML.stringify({ + type: "collection.insomnia.rest/5.0", + name: "Migration", + meta: { id: "wrk_test" }, + environments: { + meta: { id: "env_base" }, + data: { host: "https://example.com", id: "123" }, + }, + collection: requests, + }) + : JSON.stringify({ + _type: "export", + __export_format: 4, + resources: [ + { _type: "workspace", _id: "wrk_test", name: "Migration" }, + { + _type: "environment", + _id: "env_base", + parentId: "wrk_test", + data: { host: "https://example.com", id: "123" }, + }, + ...requests, + ], + }); + const imported = convertInsomnia(source)!; + const [query, login] = imported.resources.httpRequests; + expect(query?.body).toEqual({ + query: "query Get($id: ID!) { node(id: $id) { id } }", + variables: '{"id":"${[ id ]}"}', + operationName: "Get", + }); + expect(query?.url).toBe("${[ host ]}/graphql"); + expect(query?.urlParameters?.[0]?.value).toBe("${[ id ]}"); + expect(query?.headers).toContainEqual({ + name: "X-Request-ID", + value: "${[ uuid.v4() ]}", + enabled: true, + }); + expect(query?.headers).toContainEqual({ + name: "Content-Type", + value: "application/json", + enabled: true, + }); + const ref = `b64'${Buffer.from(login!.id).toString("base64url")}'`; + expect(query?.authentication?.token).toBe( + `\${[ response.body.path(request=${ref}, behavior='ttl', ttl='60', path=b64'JC50b2tlbg', result='first') ]}`, + ); + expect(imported.sourceKeys[login!.id]).toBe("req_login"); + expect(imported.sourceKeys[query!.id]).toBe("req_query"); + }); +}); diff --git a/plugins/importer-insomnia/tests/templates.test.ts b/plugins/importer-insomnia/tests/templates.test.ts new file mode 100644 index 000000000..efb35c0ef --- /dev/null +++ b/plugins/importer-insomnia/tests/templates.test.ts @@ -0,0 +1,88 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, test } from "vite-plus/test"; +import { convertTemplateSyntax } from "../src/templates"; + +const requestIds = new Set(["GENERATE_ID::req_login"]); +const convert = (text: string) => convertTemplateSyntax(text, requestIds); +const encoded = (text: string) => `b64'${Buffer.from(text).toString("base64url")}'`; + +describe("Insomnia templates", () => { + test.each(["{{ _.token }}", "{{ token }}", "{{ _['token'] }}", '{{ _ [ "token" ] }}'])( + "converts %s", + (input) => expect(convert(input)).toBe("${[ token ]}"), + ); + + test.each([ + "{{ _.token | default('fallback') }}", + "{{ _['two words'] }}", + "{{ _['a']['b'] }}", + "{{ _.a + _.b }}", + "{{ true }}", + "{% uuid 'v7' %}", + "{% faker 'name' %}", + "{% dopplersecret 'project', 'config', 'secret' %}", + "{% response 'body', 'req_missing', '$.token', 'always', 60 %}", + "{% response 'body', 'req_login', '$.token', 'unknown', 60 %}", + "{% response 'body', 'req_login', '$.token', 'always', -1 %}", + "{% response 'body' garbage, 'req_login', '$.token', 'always', 60 %}", + "{% response 'body', 'req_login', 'b64::!!::nonce', 'always', 60 %}", + "{% response 'body', 'req_login', '$.token', 'always', 60, %}", + "{% response 'body', 'req_login', '$.tokens[*]', 'always', 60 %}", + "{% response 'body', 'req_login', '$..token', 'always', 60 %}", + "{% response 'body', 'req_login', '/root/token', 'always', 60 %}", + "{% response 'body', 'req_login', '', 'never', 0 %}", + "{% response 'body', 'req_login', '$.token', 'when-expired', 0 %}", + ])("preserves unsupported or malformed syntax: %s", (input) => { + expect(convert(input)).toBe(input); + }); + + test.each(["v1", "v4"])("converts UUID %s", (version) => { + expect(convert(`{% uuid '${version}' %}`)).toBe(`\${[ uuid.${version}() ]}`); + }); + test("converts only the equivalent Faker UUID function", () => { + expect(convert("{% faker 'randomUUID' %}")).toBe("${[ uuid.v4() ]}"); + }); + + test.each([ + ["never", "never"], + ["no-history", "smart"], + ["always", "always"], + ["when-expired", "ttl"], + ])("preserves %s sending behavior", (source, target) => { + expect(convert(`{% response 'body', 'req_login', '$.token', '${source}', 60 %}`)).toBe( + `\${[ response.body.path(request=${encoded("GENERATE_ID::req_login")}, behavior='${target}', ttl='60', path=${encoded("$.token")}, result='first') ]}`, + ); + }); + + test("decodes Insomnia's UTF-8 base64 filters and emits URL-safe Yaak arguments", () => { + const path = "$.clé[0].token"; + const base64 = Buffer.from(path).toString("base64"); + expect( + convert(`{% response 'body', 'req_login', 'b64::${base64}::nonce', 'never', 0 %}`), + ).toContain(`path=${encoded(path)}`); + expect(convert(`{% response 'body', 'req_login', '${path}', 'never', 0 %}`)).toContain( + `path=${encoded(path)}`, + ); + }); + + test("maps raw body and headers, including quotes/commas in literal arguments", () => { + expect(convert("{% response 'raw', 'req_login', '', 'never', 0 %}")).toContain( + "response.body.raw(", + ); + expect( + convert("{% response 'header', 'req_login', 'x-\\'quoted,header', 'never', 0 %}"), + ).toContain(`header=${encoded("x-'quoted,header")}`); + }); + + test("converts nested resource fields without changing the input", () => { + const input = { + authentication: { token: "{{ _['token'] }}" }, + body: { form: [{ value: "{% uuid 'v4' %}" }] }, + }; + expect(convertTemplateSyntax(input)).toEqual({ + authentication: { token: "${[ token ]}" }, + body: { form: [{ value: "${[ uuid.v4() ]}" }] }, + }); + expect(input.authentication.token).toBe("{{ _['token'] }}"); + }); +}); diff --git a/plugins/template-function-response/src/index.ts b/plugins/template-function-response/src/index.ts index 3397e27d6..98f30fdca 100644 --- a/plugins/template-function-response/src/index.ts +++ b/plugins/template-function-response/src/index.ts @@ -16,6 +16,7 @@ import { filterXPath } from "../../template-function-xml"; const BEHAVIOR_TTL = "ttl"; const BEHAVIOR_ALWAYS = "always"; const BEHAVIOR_SMART = "smart"; +const BEHAVIOR_NEVER = "never"; const RETURN_FIRST = "first"; const RETURN_ALL = "all"; @@ -33,6 +34,7 @@ const behaviorArgs: DynamicTemplateFunctionArg = { { label: "When no responses", value: BEHAVIOR_SMART }, { label: "Always", value: BEHAVIOR_ALWAYS }, { label: "When expired", value: BEHAVIOR_TTL }, + { label: "Never", value: BEHAVIOR_NEVER }, ], }, { @@ -300,7 +302,7 @@ async function getResponse( const responses = await ctx.httpResponse.find({ requestId: httpRequest.id, limit: 1 }); - if (behavior === "never" && responses.length === 0) { + if (behavior === BEHAVIOR_NEVER && responses.length === 0) { return null; } @@ -308,12 +310,13 @@ async function getResponse( // Previews happen a ton, and we don't want to send too many times on "always," so treat // it as "smart" during preview. - const finalBehavior = behavior === "always" && purpose === "preview" ? "smart" : behavior; + const finalBehavior = + behavior === BEHAVIOR_ALWAYS && purpose === "preview" ? BEHAVIOR_SMART : behavior; // Send if no responses and "smart," or "always" if ( - (finalBehavior === "smart" && response == null) || - finalBehavior === "always" || + (finalBehavior === BEHAVIOR_SMART && response == null) || + finalBehavior === BEHAVIOR_ALWAYS || (finalBehavior === BEHAVIOR_TTL && shouldSendExpired(response, ttl)) ) { // Explicitly render the request before send (instead of relying on send() to render) so that we can