mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Print template strings in plain quotes unless they can't round-trip (#708)
This commit is contained in:
1 parent
6e7f23a778
commit
23d369d84e
6 files changed
+191
-19
No files matched your search
@@ -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;
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
Reference in new issue
Block a user