From 23d369d84e26b47542bbc2eae5b7bc879a9efc26 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 25 Sep 2026 09:07:28 -0700 Subject: [PATCH] Print template strings in plain quotes unless they can't round-trip (#708) --- apps/yaak-client/components/HeadersEditor.tsx | 11 +-- .../yaak-client/lib/resolvedModelName.test.ts | 51 ++++++++++ apps/yaak-client/lib/resolvedModelName.ts | 9 +- .../lib/validateHttpHeader.test.ts | 32 +++++++ apps/yaak-client/lib/validateHttpHeader.ts | 15 +++ crates/yaak-templates/src/parser.rs | 92 +++++++++++++++++-- 6 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 apps/yaak-client/lib/resolvedModelName.test.ts create mode 100644 apps/yaak-client/lib/validateHttpHeader.test.ts create mode 100644 apps/yaak-client/lib/validateHttpHeader.ts diff --git a/apps/yaak-client/components/HeadersEditor.tsx b/apps/yaak-client/components/HeadersEditor.tsx index fa1d80105..a3a499e59 100644 --- a/apps/yaak-client/components/HeadersEditor.tsx +++ b/apps/yaak-client/components/HeadersEditor.tsx @@ -9,6 +9,7 @@ import type { HeaderValuePreset } from "../lib/data/headerValuePresets"; import { headerNames } from "../lib/data/headerNames"; import { mimeTypes } from "../lib/data/mimetypes"; import { userAgents } from "../lib/data/userAgents"; +import { validateHttpHeader } from "../lib/validateHttpHeader"; import { CountBadge } from "./core/CountBadge"; import { DetailsBanner } from "./core/DetailsBanner"; import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion"; @@ -178,13 +179,3 @@ const nameAutocomplete: PairEditorProps["nameAutocomplete"] = { }, ), }; - -const validateHttpHeader = (v: string) => { - if (v === "") { - return true; - } - - // Template strings are not allowed so we replace them with a valid example string - const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123"); - return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null; -}; diff --git a/apps/yaak-client/lib/resolvedModelName.test.ts b/apps/yaak-client/lib/resolvedModelName.test.ts new file mode 100644 index 000000000..a9e8ea12a --- /dev/null +++ b/apps/yaak-client/lib/resolvedModelName.test.ts @@ -0,0 +1,51 @@ +import type { HttpRequest } from "@yaakapp-internal/models"; +import { describe, expect, test, vi } from "vite-plus/test"; + +// resolvedModelName only reads plain model fields, but its module imports pull in the +// platform singleton, which needs a browser. Stub them out to keep this a pure unit test. +vi.mock("@yaakapp-internal/models", () => ({ foldersAtom: {} })); +vi.mock("./jotai", () => ({ jotaiStore: { get: () => [] } })); + +const { resolvedModelName } = await import("./resolvedModelName"); + +function httpRequest(url: string): HttpRequest { + return { id: "rq_test", model: "http_request", name: "", url } as HttpRequest; +} + +describe("resolvedModelName", () => { + test("returns the name when there is one", () => { + expect( + resolvedModelName({ ...httpRequest("https://example.com"), name: "My Request" }), + ).toEqual("My Request"); + }); + + test("replaces a simple tag with its contents", () => { + expect(resolvedModelName(httpRequest("${[ base_url ]}/users"))).toEqual("base_url/users"); + }); + + test("replaces a tag containing a quoted argument with a space", () => { + expect(resolvedModelName(httpRequest("${[ fn(arg='my key') ]}/users"))).toEqual( + "fn(arg='my key')/users", + ); + }); + + test("replaces each tag in a multi-tag string", () => { + expect( + resolvedModelName(httpRequest("${[ fn(arg='my key') ]}/a/${[ other(b='x y') ]}/b")), + ).toEqual("fn(arg='my key')/a/other(b='x y')/b"); + }); + + test("stops each tag at its first closing bracket", () => { + expect(resolvedModelName(httpRequest("${[ a ]}${[ b ]}"))).toEqual("ab"); + }); + + test("strips the protocol", () => { + expect(resolvedModelName(httpRequest("https://example.com/${[ path ]}"))).toEqual( + "example.com/path", + ); + }); + + test("falls back to a generic name when the url is empty", () => { + expect(resolvedModelName(httpRequest(""))).toEqual("HTTP Request"); + }); +}); diff --git a/apps/yaak-client/lib/resolvedModelName.ts b/apps/yaak-client/lib/resolvedModelName.ts index 1e84fc772..fe2c23de7 100644 --- a/apps/yaak-client/lib/resolvedModelName.ts +++ b/apps/yaak-client/lib/resolvedModelName.ts @@ -2,6 +2,13 @@ import type { AnyModel } from "@yaakapp-internal/models"; import { foldersAtom } from "@yaakapp-internal/models"; import { jotaiStore } from "./jotai"; +/** + * Matches a template tag up to the first `]}`, the same way the Lezer grammar and the JSON + * linter close a tag. Quoted arguments may contain spaces, so this can't stop at whitespace. + * The capture group holds the tag's inner content, trimmed of the padding spaces. + */ +const TEMPLATE_TAG_REGEX = /\$\{\[\s*([\s\S]*?)\s*]}/g; + export function resolvedModelName(r: AnyModel | null): string { if (r == null) return ""; @@ -15,7 +22,7 @@ export function resolvedModelName(r: AnyModel | null): string { } // Replace variable syntax with variable name - const withoutVariables = r.url.replace(/\$\{\[\s*([^\]\s]+)\s*]}/g, "$1"); + const withoutVariables = r.url.replace(TEMPLATE_TAG_REGEX, "$1"); if (withoutVariables.trim() === "") { return r.model === "http_request" ? r.bodyType && r.bodyType === "graphql" diff --git a/apps/yaak-client/lib/validateHttpHeader.test.ts b/apps/yaak-client/lib/validateHttpHeader.test.ts new file mode 100644 index 000000000..365592527 --- /dev/null +++ b/apps/yaak-client/lib/validateHttpHeader.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vite-plus/test"; +import { validateHttpHeader } from "./validateHttpHeader"; + +describe("validateHttpHeader", () => { + test("allows an empty value", () => { + expect(validateHttpHeader("")).toEqual(true); + }); + + test("allows a plain header name", () => { + expect(validateHttpHeader("X-Api-Key")).toEqual(true); + }); + + test("rejects a header name with a space", () => { + expect(validateHttpHeader("X Api Key")).toEqual(false); + }); + + test("allows a simple template tag", () => { + expect(validateHttpHeader("${[ my_var ]}")).toEqual(true); + }); + + test("allows a tag containing a quoted argument with a space", () => { + expect(validateHttpHeader("${[ fn(arg='my key') ]}")).toEqual(true); + }); + + test("allows a multi-tag value", () => { + expect(validateHttpHeader("${[ fn(arg='my key') ]}-${[ other(b='x y') ]}")).toEqual(true); + }); + + test("rejects a tag followed by an invalid character", () => { + expect(validateHttpHeader("${[ my_var ]} oops")).toEqual(false); + }); +}); diff --git a/apps/yaak-client/lib/validateHttpHeader.ts b/apps/yaak-client/lib/validateHttpHeader.ts new file mode 100644 index 000000000..1441f9244 --- /dev/null +++ b/apps/yaak-client/lib/validateHttpHeader.ts @@ -0,0 +1,15 @@ +/** + * Matches a template tag up to the first `]}`, the same way the Lezer grammar and the JSON + * linter close a tag. Quoted arguments may contain spaces, so this can't stop at whitespace. + */ +const TEMPLATE_TAG_REGEX = /\$\{\[[\s\S]*?]}/g; + +export function validateHttpHeader(v: string): boolean { + if (v === "") { + return true; + } + + // Template strings are not allowed so we replace them with a valid example string + const withoutTemplateStrings = v.replace(TEMPLATE_TAG_REGEX, "123"); + return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null; +} diff --git a/crates/yaak-templates/src/parser.rs b/crates/yaak-templates/src/parser.rs index 21ab2e8be..12230fc33 100644 --- a/crates/yaak-templates/src/parser.rs +++ b/crates/yaak-templates/src/parser.rs @@ -45,14 +45,36 @@ pub enum Val { Null, } +/// Strings are printed as plain single-quoted values whenever they can survive a round-trip +/// through both the parser and the client's editor. The editor can't handle `]}` inside a tag +/// (the Lezer grammar and the JSON linter both close a tag at the first one, regardless of +/// quoting), and newlines or other control characters don't belong in a single-line tag, so +/// those cases fall back to base64. +fn requires_b64(text: &str) -> bool { + text.contains("]}") || text.chars().any(char::is_control) +} + +/// Escape the only two characters the parser treats specially inside a single-quoted string. +fn escape_str(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\'' => out.push_str("\\'"), + _ => out.push(c), + } + } + out +} + impl Display for Val { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let str = match self { Val::Str { text } => { - if text.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '_') { - format!("'{}'", text) - } else { + if requires_b64(text) { format!("b64'{}'", BASE64_URL_SAFE_NO_PAD.encode(text)) + } else { + format!("'{}'", escape_str(text)) } } Val::Var { name } => name.to_string(), @@ -835,10 +857,64 @@ mod tests { #[test] fn token_display_complex_str() -> Result<()> { - assert_eq!( - Val::Str { text: "Hello 'You'".to_string() }.to_string(), - "b64'SGVsbG8gJ1lvdSc'" - ); + assert_eq!(Val::Str { text: "Hello 'You'".to_string() }.to_string(), r#"'Hello \'You\''"#); + + Ok(()) + } + + #[test] + fn token_display_str_plain() -> Result<()> { + // Strings the parser can read back verbatim print in plain quotes + assert_eq!(Val::Str { text: "X-Api-Key".to_string() }.to_string(), "'X-Api-Key'"); + assert_eq!(Val::Str { text: "$.token".to_string() }.to_string(), "'$.token'"); + assert_eq!(Val::Str { text: "it's".to_string() }.to_string(), r#"'it\'s'"#); + assert_eq!(Val::Str { text: r"back\slash".to_string() }.to_string(), r"'back\\slash'"); + assert_eq!(Val::Str { text: r#"{"a":1}"#.to_string() }.to_string(), r#"'{"a":1}'"#); + assert_eq!(Val::Str { text: "héllo".to_string() }.to_string(), "'héllo'"); + + Ok(()) + } + + #[test] + fn token_display_str_b64_fallback() -> Result<()> { + // `]}` would close the tag early in the client editor + assert_eq!(Val::Str { text: "a]}b".to_string() }.to_string(), "b64'YV19Yg'"); + + // Control characters don't belong in a single-line tag + assert_eq!(Val::Str { text: "line\nbreak".to_string() }.to_string(), "b64'bGluZQpicmVhaw'"); + assert_eq!(Val::Str { text: "line\rbreak".to_string() }.to_string(), "b64'bGluZQ1icmVhaw'"); + assert_eq!(Val::Str { text: "tab\there".to_string() }.to_string(), "b64'dGFiCWhlcmU'"); + + Ok(()) + } + + #[test] + fn token_str_round_trip() -> Result<()> { + let cases = [ + "X-Api-Key", + "$.token", + "hello world", + "it's", + r"back\slash", + r#"{"a":1}"#, + "a]}b", + "line\nbreak", + "héllo", + r"trailing\", + "'", + "", + ]; + + for text in cases { + let val = Val::Str { text: text.to_string() }; + let template = Token::Tag { val: val.clone() }.to_string(); + let tokens = Parser::new(&template).parse()?; + assert_eq!( + tokens.tokens, + vec![Token::Tag { val: val.clone() }, Token::Eof], + "failed to round-trip {text:?} (printed as {template})" + ); + } Ok(()) } @@ -879,7 +955,7 @@ mod tests { } } .to_string(), - r#"${[ foo(arg=b64'diAneCc', arg2=my_var) ]}"# + r#"${[ foo(arg='v \'x\'', arg2=my_var) ]}"# ); Ok(())