Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/yaak-client/components/HttpRequestPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
Expand Down Expand Up @@ -131,9 +132,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
);

const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? "",
);
const placeholderNames = extractPathPlaceholders(activeRequest.url);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
Expand Down
5 changes: 2 additions & 3 deletions apps/yaak-client/components/WebsocketRequestPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge";
Expand Down Expand Up @@ -83,9 +84,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
);

const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? "",
);
const placeholderNames = extractPathPlaceholders(activeRequest.url);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,20 @@ function pathParameters(
to,
enter(node) {
if (node.name === "Text") {
// Find the `url` node and then jump into it to find the placeholders
// Find the URL overlay root. With `Host?` optional, a path-only URL like
// `/:foo/:bar` produces `Path` as the topmost overlay node instead of `url`,
// so accept either.
for (let i = node.from; i < node.to; i++) {
const innerTree = syntaxTree(view.state).resolveInner(i);
if (innerTree.node.name === "url") {
if (innerTree.node.name === "url" || innerTree.node.name === "Path") {
innerTree.toTree().iterate({
enter(node) {
if (node.name !== "Placeholder") return;
const globalFrom = innerTree.node.from + node.from;
const globalTo = innerTree.node.from + node.to;
// A real path placeholder is preceded by `/`. This filters mid-segment
// Placeholder nodes (e.g. trailing `:literal` after `:id:literal`).
if (view.state.doc.sliceString(globalFrom - 1, globalFrom) !== "/") return;
const rawText = view.state.doc.sliceString(globalFrom, globalTo);
const onClick = () => onClickPathParameter(rawText);
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
Expand Down
5 changes: 4 additions & 1 deletion apps/yaak-client/components/core/Editor/url/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { styleTags, tags as t } from "@lezer/highlight";

export const highlight = styleTags({
Protocol: t.comment,
Placeholder: t.emphasis,
// Placeholder nodes are rendered as chip widgets by `pathParameters.ts`, which
// replaces the underlying text — so a style on the text itself is invisible for
// valid placeholders and only ever appears on the spurious nodes the widget
// plugin filters out (e.g. the trailing `:literal` in `/:id:literal`).
// PathSegment: t.tagName,
// Host: t.variableName,
// Path: t.bool,
Expand Down
13 changes: 10 additions & 3 deletions apps/yaak-client/components/core/Editor/url/url.grammar
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
@top url { Protocol? Host Path? Query? }
// Host is optional so URLs starting with `/` go straight to Path. Without this,
// the parser error-recovers past the leading `/` and consumes the first segment as
// Host (since Host's char class includes `:` for `host:port`), eating an initial
// `:name` placeholder like `/:foo/:bar`.
@top url { Protocol? Host? Path? Query? }

Path { ("/" (Placeholder | PathSegment))+ }
Path { ("/" (Placeholder PathSegment? | PathSegment))+ }

Query { "?" queryPair ("&" queryPair)* }

Expand All @@ -9,7 +13,10 @@ Query { "?" queryPair ("&" queryPair)* }
Host { $[a-zA-Z0-9-_.:\[\]]+ }
@precedence { Protocol, Host }

Placeholder { ":" ![/?#]+ }
// Placeholder name excludes `:` so a literal colon ends the placeholder. `/:abc:def`
// parses as Placeholder(:abc) + PathSegment(:def). `/abc:def` is all literal since
// the segment doesn't start with `:`.
Placeholder { ":" ![/?#:]+ }
PathSegment { ![?#/]+ }
@precedence { Placeholder, PathSegment }

Expand Down
12 changes: 6 additions & 6 deletions apps/yaak-client/components/core/Editor/url/url.terms.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const url = 1,
export const
url = 1,
Protocol = 2,
Host = 3,
Port = 4,
Path = 5,
Placeholder = 6,
PathSegment = 7,
Query = 8;
Path = 4,
Placeholder = 5,
PathSegment = 6,
Query = 7
39 changes: 39 additions & 0 deletions apps/yaak-client/components/core/Editor/url/url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./url";

function placeholderStarts(input: string): number[] {
const positions: number[] = [];
parser
.parse(input)
.cursor()
.iterate((node) => {
if (node.name === "Placeholder") positions.push(node.from);
});
return positions;
}

describe("URL grammar Placeholder", () => {
test("recognized after `/`", () => {
const url = "https://x.com/users/:id";
const [pos] = placeholderStarts(url);
expect(url[pos - 1]).toBe("/");
});

test("lexer over-emits a second Placeholder after a literal `:` (filter relies on this)", () => {
const url = "https://x.com/x/:id:def";
const positions = placeholderStarts(url);
expect(positions.length).toBe(2);
expect(url[positions[0] - 1]).toBe("/");
expect(url[positions[1] - 1]).not.toBe("/");
});

test("first segment of a path-only URL is a Placeholder, not eaten as Host", () => {
// Regression: without `Host?`, the first `:chip1` would be tokenized as Host
// (Host's char class includes `:` for `host:port`), leaving only `:chip2` as
// a Placeholder.
const url = "/:chip1/:chip2";
const positions = placeholderStarts(url);
expect(positions.length).toBe(2);
expect(url.slice(positions[0])).toMatch(/^:chip1/);
});
});
10 changes: 5 additions & 5 deletions apps/yaak-client/components/core/Editor/url/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ import { highlight } from "./highlight";
export const parser = LRParser.deserialize({
version: 14,
states:
"!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c",
stateData: "!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~",
goto: "nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[",
"#YQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO,59OOOOO,59O,59OOOOO-E6b-E6bO!WOPO,58}OOOO1G.j1G.jO!`OSO'#CeO!eOPO1G.iOOOO,59P,59POOOO-E6c-E6c",
stateData: "!s~OQVORUOZPO[RO~OTWOUXO~OZPOYSX[SX~O]ZO~OU[OYWaZWa[Wa~O^]OYVa~O]_O~O^]OYVi~OQRTUT~",
goto: "tYPPPPZPP`fnVTOUVXSOTUVUQOUVRYQQ^ZR`^",
nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query",
maxTerm: 14,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 2,
tokenData:
".i~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+W!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)RQ)YUTQUQOs)Rt!P)R!Q!a)R!b;'S)R;'S;=`)l<%lO)RQ)oP;=`<%l)RR){cRPTQUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)R~+]O[~V+fe]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],w!]!_!j!_!`&u!`!a!j!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jR-OdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.^!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.aP!P!Q.dP.iOQP",
".o~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+^!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)RQ)YWTQUQOs)Rt!P)R!Q![)R![!]!j!]!a)R!b;'S)R;'S;=`)r<%lO)RQ)uP;=`<%l)RR*RcRPTQUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)R~+cO[~V+le]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],}!]!_!j!_!`&u!`!a!j!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jR-UdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.d!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.gP!P!Q.jP.oOQP",
tokenizers: [0, 1, 2],
topRules: { url: [0, 1] },
tokenPrec: 63,
tokenPrec: 75,
});
28 changes: 28 additions & 0 deletions apps/yaak-client/lib/pathPlaceholders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vite-plus/test";
import { extractPathPlaceholders } from "./pathPlaceholders";

describe("extractPathPlaceholders", () => {
test("extracts a single placeholder", () => {
expect(extractPathPlaceholders("/users/:id")).toEqual([":id"]);
});

test("extracts multiple placeholders", () => {
expect(extractPathPlaceholders("/users/:id/posts/:postId")).toEqual([":id", ":postId"]);
});

test("stops at a literal `:` in the same segment", () => {
expect(extractPathPlaceholders("/tasks/:id:cancel")).toEqual([":id"]);
});

test("does not match `:foo` mid-segment", () => {
expect(extractPathPlaceholders("/users/abc:def")).toEqual([]);
});

test("does not match `:` in a host port", () => {
expect(extractPathPlaceholders("https://example.com:8080/users/:id")).toEqual([":id"]);
});

test("returns empty for a URL with no placeholders", () => {
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
});
});
14 changes: 14 additions & 0 deletions apps/yaak-client/lib/pathPlaceholders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Extract `:name`-style path placeholders from a URL string.
*
* A placeholder is `:` followed by one-or-more characters that are not `/`, `?`,
* `#`, or `:`. The `:` boundary means a placeholder ends where a literal colon
* starts in the same segment, e.g. `/tasks/:id:increment-importance` yields one
* placeholder `:id` and `:increment-importance` is literal text.
*
* Only `:` that sits at the start of a `/`-delimited segment counts — `/abc:def`
* has no placeholders. Returned names include the leading colon.
*/
export function extractPathPlaceholders(url: string): string[] {
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
}
17 changes: 16 additions & 1 deletion crates/yaak-http/src/path_placeholders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
return url.to_string();
}

let re = regex::Regex::new(format!("(/){}([/?#]|$)", p.name).as_str()).unwrap();
// A path placeholder is terminated by `/`, `?`, `#`, end-of-string, or a literal `:`.
// The `:` boundary is what lets `/:id:increment-importance` substitute the `:id`
// placeholder while leaving `:increment-importance` as literal text.
let re = regex::Regex::new(format!("(/){}([/?#:]|$)", p.name).as_str()).unwrap();
let result = re
.replace_all(url, |cap: &regex::Captures| {
format!(
Expand Down Expand Up @@ -83,6 +86,18 @@ mod placeholder_tests {
);
}

#[test]
fn placeholder_followed_by_literal_colon() {
// AIP-136-style custom method: `:id` is the placeholder, `:increment-importance`
// is literal text in the same path segment.
let p =
HttpUrlParameter { name: ":id".into(), value: "42".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/tasks/:id:increment-importance"),
"https://example.com/tasks/42:increment-importance",
);
}

#[test]
fn placeholder_missing() {
let p = HttpUrlParameter {
Expand Down