mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Fix Insomnia template and GraphQL import fidelity (#693)
This commit is contained in:
1 parent
6fc43a60e2
commit
34815f327a
13 files changed
+917
-31
No files matched your search
+107
-2
@@ -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<String>| 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::<Value>(&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())
|
||||
}
|
||||
|
||||
@@ -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<String, String>,
|
||||
) -> 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<String, String>) {
|
||||
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<String, String>) -> 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, String>) -> 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<String, String> {
|
||||
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' %}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod error;
|
||||
pub mod import;
|
||||
mod import_templates;
|
||||
pub mod plugin_events;
|
||||
pub mod response_body;
|
||||
pub mod send;
|
||||
|
||||
@@ -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<string, string> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("{")) return null;
|
||||
|
||||
const members = new Map<string, string>();
|
||||
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<T>(obj: T): T {
|
||||
if (Array.isArray(obj) && obj != null) {
|
||||
return obj.map(deleteUndefinedAttrs) as T;
|
||||
@@ -128,20 +262,3 @@ export function deleteUndefinedAttrs<T>(obj: T): T {
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/** Recursively render all nested object properties */
|
||||
export function convertTemplateSyntax<T>(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;
|
||||
}
|
||||
@@ -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>): 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<string, string> = {
|
||||
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<T>(obj: T, requestIds = new Set<string>()): 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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'] }}");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user