fix(web): expand resource templates per RFC 6570

The Resources screen discovered and substituted template variables with a
bare `/\{(\w+)\}/g` regex, which is wrong two ways: it cannot see an
expression carrying an operator, so `foobar://events{?topic}` rendered no
input at all; and it splices the raw value in, so a `/`, `?`, `#`, `%`,
space or non-ASCII character in a simple `{topic}` landed unencoded and
changed the URI's structure -- `foo/bar` produced an extra path segment
that a conforming matcher rejects with `-32602 Resource not found`.

Delegate expansion to the SDK's `UriTemplate`, the same implementation
`InspectorClient.readResourceFromTemplate` (and so the TUI) already
expands through, so the two clients cannot disagree about what a template
means. The new `utils/uriTemplate` supplies only what that class does
not: which variables to render an input for, which of them a read cannot
proceed without, and a partially-expanded preview.

Required-ness follows the operator. Under `?`, `&`, `.` or `/` the whole
expression is omitted when the variable is undefined, so those fields are
marked Optional and reading with them blank is a legitimate request for
the unfiltered resource; under `""`, `+` or `#` the variable sits mid-URI,
so it stays required. Blank fields are dropped before expanding so an
untouched optional field reads as undefined rather than as the empty
string, which would expand to a valueless `?topic=`.

Adds the `rfc6570_templates` preset and a `rfc6570-templates-http.json`
showcase server serving the two templates from the issue.

Closes #1919

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 18:31:35 -04:00
commit 83658db2dd

View file

@ -27,6 +27,11 @@ const noVarTemplate: ResourceTemplate = {
uriTemplate: "file:///static.txt",
};
const queryTemplate: ResourceTemplate = {
name: "Events",
uriTemplate: "foobar://events{?topic}",
};
describe("ResourceTemplatePanel", () => {
it("renders the template title (or name) and description", () => {
renderWithMantine(
@ -154,6 +159,92 @@ describe("ResourceTemplatePanel", () => {
).not.toBeDisabled();
});
describe("RFC 6570 expansion (#1919)", () => {
it("renders an input for a query expression the old regex could not see", () => {
renderWithMantine(
<ResourceTemplatePanel
template={queryTemplate}
onReadResource={vi.fn()}
/>,
);
expect(screen.getByLabelText("topic")).toBeInTheDocument();
});
it("percent-encodes a reserved character in a simple variable", async () => {
const user = userEvent.setup();
const onReadResource = vi.fn();
renderWithMantine(
<ResourceTemplatePanel
template={singleVarTemplate}
onReadResource={onReadResource}
/>,
);
await user.type(screen.getByLabelText("userId"), "foo/bar");
await user.click(screen.getByRole("button", { name: "Read Resource" }));
expect(onReadResource).toHaveBeenCalledWith(
"file:///users/foo%2Fbar/profile",
);
});
it("builds an encoded query expression for {?topic}", async () => {
const user = userEvent.setup();
const onReadResource = vi.fn();
renderWithMantine(
<ResourceTemplatePanel
template={queryTemplate}
onReadResource={onReadResource}
/>,
);
await user.type(screen.getByLabelText("topic"), "foo/bar");
await user.click(screen.getByRole("button", { name: "Read Resource" }));
expect(onReadResource).toHaveBeenCalledWith(
"foobar://events?topic=foo%2Fbar",
);
});
it("marks a query variable Optional and does not gate the read on it", async () => {
const user = userEvent.setup();
const onReadResource = vi.fn();
renderWithMantine(
<ResourceTemplatePanel
template={queryTemplate}
onReadResource={onReadResource}
/>,
);
expect(screen.getByText("Optional")).toBeInTheDocument();
const button = screen.getByRole("button", { name: "Read Resource" });
expect(button).not.toBeDisabled();
// Left blank, the whole expression drops out per RFC 6570.
await user.click(button);
expect(onReadResource).toHaveBeenCalledWith("foobar://events");
});
it("does not mark a required simple variable Optional", () => {
renderWithMantine(
<ResourceTemplatePanel
template={singleVarTemplate}
onReadResource={vi.fn()}
/>,
);
expect(screen.queryByText("Optional")).not.toBeInTheDocument();
});
it("previews the query expression verbatim until it is filled", async () => {
const user = userEvent.setup();
renderWithMantine(
<ResourceTemplatePanel
template={queryTemplate}
onReadResource={vi.fn()}
/>,
);
expect(screen.getByText("foobar://events{?topic}")).toBeInTheDocument();
await user.type(screen.getByLabelText("topic"), "news");
expect(
screen.getByText("foobar://events?topic=news"),
).toBeInTheDocument();
});
});
describe("completions", () => {
it("fires a completion immediately on focus before any keystroke", async () => {
const user = userEvent.setup();