Merge pull request #2035 from modelcontextprotocol/v2/fix/1919-rfc6570-uri-template-expansion
fix(web): expand resource templates per RFC 6570
This commit is contained in:
commit
f8d8c77783
6 changed files with 3060 additions and 59 deletions
|
|
@ -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,200 @@ 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();
|
||||
});
|
||||
|
||||
// A template the server advertised can be malformed. There is no URI to
|
||||
// send for one, so the read is withheld and the reason shown -- reading the
|
||||
// raw template with its braces intact would draw a confusing "not found"
|
||||
// from the server for a defect that is not the user's.
|
||||
it.each([
|
||||
["an out-of-grammar prefix modifier", "x://items/{id:abc}"],
|
||||
["an expression declaring no variable", "x://items/{}"],
|
||||
])("refuses to read a template with %s", (_label, uriTemplate) => {
|
||||
const onReadResource = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Broken", uriTemplate }}
|
||||
onReadResource={onReadResource}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Read Resource" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByText(/Invalid RFC 6570 varspec/)).toBeInTheDocument();
|
||||
expect(onReadResource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names every unmet group, so a disabled button always has a reason", async () => {
|
||||
// With `{a,b}{b,c}{a,c}`, filling `b` satisfies the first two groups. A
|
||||
// per-field hint then looks satisfied while Read Resource stays disabled
|
||||
// on the third -- so the outstanding requirement is stated form-level.
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Shared", uriTemplate: "x://{a,b}{b,c}{a,c}" }}
|
||||
onReadResource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Still needed:/)).toHaveTextContent(
|
||||
"Still needed: a or b; b or c; a or c",
|
||||
);
|
||||
await user.type(screen.getByLabelText("b"), "1");
|
||||
expect(screen.getByText(/Still needed:/)).toHaveTextContent(
|
||||
"Still needed: a or c",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Read Resource" }),
|
||||
).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("a"), "2");
|
||||
expect(screen.queryByText(/Still needed:/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Read Resource" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("hints every shared group a variable sits in, not just the first", () => {
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Shared", uriTemplate: "x://{a,b}{a,c}" }}
|
||||
onReadResource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Any one of each: a, b; a, c"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats a name repeated in one expression as a plain requirement", () => {
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Twice", uriTemplate: "x://{a,a}" }}
|
||||
onReadResource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Not "Any one of: a, a" -- it is one field, required.
|
||||
expect(screen.queryByText(/Any one of/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Still needed: a")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an autocomplete field for a name that collides with Object.prototype", async () => {
|
||||
// `toString` is a valid RFC 6570 varname and `completions` starts empty,
|
||||
// so a bare `completions[varName] ?? []` handed Mantine the prototype's
|
||||
// *function* as its options array (`??` catches only null/undefined) and
|
||||
// the field crashed on first render.
|
||||
const user = userEvent.setup();
|
||||
const onCompleteArgument = vi.fn().mockResolvedValue(["a", "b"]);
|
||||
const onReadResource = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Proto", uriTemplate: "x://{toString}" }}
|
||||
onReadResource={onReadResource}
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
completionsSupported
|
||||
/>,
|
||||
);
|
||||
// By role, not label: "toString" also appears in the "Still needed" line.
|
||||
const input = screen.getByRole("textbox", { name: "toString" });
|
||||
expect(input).toBeInTheDocument();
|
||||
await user.type(input, "v");
|
||||
await user.click(screen.getByRole("button", { name: "Read Resource" }));
|
||||
expect(onReadResource).toHaveBeenCalledWith("x://v");
|
||||
});
|
||||
|
||||
it("shows the malformed template unexpanded in the preview", () => {
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Broken", uriTemplate: "x://items/{}" }}
|
||||
onReadResource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("x://items/{}")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completions", () => {
|
||||
it("fires a completion immediately on focus before any keystroke", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ import { useValueChange } from "../../../hooks/useValueChange";
|
|||
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
||||
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||
import {
|
||||
definedValues,
|
||||
previewUriTemplate,
|
||||
requiredGroups,
|
||||
templateVariables,
|
||||
tryExpandUriTemplate,
|
||||
unmetRequiredGroups,
|
||||
} from "../../../utils/uriTemplate";
|
||||
|
||||
export interface ResourceTemplatePanelProps {
|
||||
template: ResourceTemplate;
|
||||
|
|
@ -39,32 +47,22 @@ export interface ResourceTemplatePanelProps {
|
|||
|
||||
const COMPLETION_DEBOUNCE_MS = 300;
|
||||
|
||||
function parseVariableNames(uriTemplate: string): string[] {
|
||||
const names: string[] = [];
|
||||
const regex = /\{(\w+)\}/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(uriTemplate)) !== null) {
|
||||
names.push(match[1]);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function resolveUri(
|
||||
uriTemplate: string,
|
||||
variables: Record<string, string>,
|
||||
): string {
|
||||
return uriTemplate.replace(/\{(\w+)\}/g, (_, key: string) => variables[key]);
|
||||
}
|
||||
|
||||
function previewUri(
|
||||
uriTemplate: string,
|
||||
variables: Record<string, string>,
|
||||
): string {
|
||||
return uriTemplate.replace(/\{(\w+)\}/g, (match, key: string) =>
|
||||
variables[key]?.length > 0 ? variables[key] : match,
|
||||
);
|
||||
/**
|
||||
* The completions fetched for one variable, ignoring anything inherited from
|
||||
* `Object.prototype`.
|
||||
*
|
||||
* `toString`, `constructor` and `__proto__` are valid RFC 6570 variable names,
|
||||
* and this map starts empty — so a bare `completions[varName] ?? []` returned
|
||||
* the prototype's *function* for such a name (`??` only catches null and
|
||||
* undefined) and handed it to Mantine as its `data` array, crashing the field
|
||||
* on first render. Same hazard the expansion path fixed for values; this is
|
||||
* the one place the component reads a name-keyed map it did not seed.
|
||||
*/
|
||||
function completionsFor(
|
||||
completions: Record<string, string[]>,
|
||||
varName: string,
|
||||
): string[] {
|
||||
return Object.hasOwn(completions, varName) ? completions[varName] : [];
|
||||
}
|
||||
|
||||
const HeaderRow = Group.withProps({
|
||||
|
|
@ -90,6 +88,26 @@ const DescriptionText = Text.withProps({
|
|||
c: "dimmed",
|
||||
});
|
||||
|
||||
// Why Read Resource is disabled when the template itself cannot be expanded.
|
||||
// Without it the button is inert with nothing on screen explaining the refusal.
|
||||
//
|
||||
// `role="alert"` because the message can appear *while typing* -- a pasted
|
||||
// value that cannot be encoded -- so a screen-reader user would otherwise meet
|
||||
// a silently disabled action. An alert is an assertive live region, which is
|
||||
// what announces text that arrives after first render.
|
||||
// What is still missing before Read Resource can fire. Dimmed rather than red:
|
||||
// an incomplete form is the expected starting state, not an error.
|
||||
const RequirementText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
});
|
||||
|
||||
const ExpansionErrorText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "red",
|
||||
role: "alert",
|
||||
});
|
||||
|
||||
// Left-aligned so the action sits closest to the sidebar controls / the form
|
||||
// fields above; annotation badges trail it.
|
||||
const FooterRow = Group.withProps({
|
||||
|
|
@ -108,10 +126,20 @@ export function ResourceTemplatePanel({
|
|||
}: ResourceTemplatePanelProps) {
|
||||
const { name, title, uriTemplate, description, annotations } = template;
|
||||
|
||||
const variableNames = useMemo(
|
||||
() => parseVariableNames(uriTemplate),
|
||||
// Every variable the template declares, with the operator it appears under
|
||||
// and whether omitting it would change the URI's shape (see `utils/uriTemplate`).
|
||||
const declaredVariables = useMemo(
|
||||
() => templateVariables(uriTemplate),
|
||||
[uriTemplate],
|
||||
);
|
||||
const variableNames = useMemo(
|
||||
() => declaredVariables.map((v) => v.name),
|
||||
[declaredVariables],
|
||||
);
|
||||
// The names of each expression that cannot be omitted. Kept separate from
|
||||
// `declaredVariables` (which is deduplicated for rendering) because each
|
||||
// required expression has to be satisfied on its own — see `requiredGroups`.
|
||||
const groups = useMemo(() => requiredGroups(uriTemplate), [uriTemplate]);
|
||||
|
||||
const [variables, setVariables] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(variableNames.map((n) => [n, ""])),
|
||||
|
|
@ -196,7 +224,8 @@ export function ResourceTemplatePanel({
|
|||
// show ghost suggestions from the old keystroke while the new
|
||||
// request is in flight (300ms debounce + network latency).
|
||||
setCompletions((prev) => {
|
||||
if (prev[varName] === undefined) return prev;
|
||||
// Own-property, as above: an inherited member is not a stale dropdown.
|
||||
if (!Object.hasOwn(prev, varName)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[varName];
|
||||
return next;
|
||||
|
|
@ -232,13 +261,40 @@ export function ResourceTemplatePanel({
|
|||
void runCompletion(varName, value, buildContext(varName));
|
||||
}
|
||||
|
||||
const canSubmit = variableNames.every((n) => variables[n]?.length > 0);
|
||||
// Two independent gates on the read.
|
||||
//
|
||||
// First, whether the values cover what expansion structurally needs. Only the
|
||||
// expressions whose absence would change the URI's shape count; an unfilled
|
||||
// `{?topic}` is a legitimate request for the unfiltered resource, and RFC 6570
|
||||
// drops the whole expression for it. The rule is per-expression rather than
|
||||
// per-variable -- `{a,b}` with only `a` filled expands to `a`'s value -- so it
|
||||
// lives in core beside the expander.
|
||||
//
|
||||
// Second, whether the template expands at all. A template the server
|
||||
// advertised can be malformed (`{id:abc}`, `{a,}`) and a pasted value can be
|
||||
// unencodable, and in neither case is there a URI to send -- so withhold the
|
||||
// request and say why, rather than reading the raw template with its braces
|
||||
// intact and letting the server answer with a confusing "not found".
|
||||
// `definedValues` is applied here rather than inside the expander: a key
|
||||
// present with `""` is a *defined* RFC 6570 value and legitimately expands to
|
||||
// `?topic=`, but this form seeds every declared variable with `""`, so an
|
||||
// untouched field is indistinguishable from a deliberately empty one. That is
|
||||
// a fact about the form, so the form is what resolves it.
|
||||
const expansion = tryExpandUriTemplate(uriTemplate, definedValues(variables));
|
||||
// Named rather than merely counted, so a disabled Read Resource always has a
|
||||
// reason on screen. Per-field hints cannot carry this: a variable shared
|
||||
// across several groups looks satisfied once any one of them is met.
|
||||
const unmet = unmetRequiredGroups(groups, variables);
|
||||
const canSubmit = expansion.error === undefined && unmet.length === 0;
|
||||
|
||||
function handleSubmit() {
|
||||
onReadResource(resolveUri(uriTemplate, variables));
|
||||
/* v8 ignore next -- unreachable: the button is disabled whenever the
|
||||
expansion failed, which is the only way `uri` is undefined. */
|
||||
if (expansion.uri === undefined) return;
|
||||
onReadResource(expansion.uri);
|
||||
}
|
||||
|
||||
const preview = previewUri(uriTemplate, variables);
|
||||
const preview = previewUriTemplate(uriTemplate, variables);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
|
|
@ -251,16 +307,47 @@ export function ResourceTemplatePanel({
|
|||
</HeaderRow>
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
<Stack gap="sm">
|
||||
{variableNames.map((varName) => {
|
||||
{declaredVariables.map(({ name: varName, required }) => {
|
||||
/* v8 ignore next -- `?? ""` fallback unreachable: `variables` is seeded with every declared variable, so the key is always present. */
|
||||
const fieldValue = variables[varName] ?? "";
|
||||
// RFC 6570 omits an undefined variable under a query/path-segment
|
||||
// operator entirely, so those fields are genuinely optional. In a
|
||||
// required multi-name expression no single field is mandatory either
|
||||
// -- any one of them satisfies it -- so say which, rather than
|
||||
// marking each one required and blocking valid input.
|
||||
// A name can sit in a singleton required group *and* a shared one
|
||||
// (`x://{a}/{a,b}`). The singleton demands this exact field, so the
|
||||
// "any one of" hint would contradict the disabled submit button --
|
||||
// suppress it and let the field read as plainly required.
|
||||
const individuallyRequired = groups.some(
|
||||
(names) => names.length === 1 && names[0] === varName,
|
||||
);
|
||||
// EVERY shared group this variable sits in, not the first. With
|
||||
// `{a,b}{b,c}{a,c}`, showing only the first left `b` looking like it
|
||||
// satisfied everything while Read Resource stayed disabled on the
|
||||
// unmet `{a,c}` -- a hidden requirement is worse than a wordy hint.
|
||||
// The form-level "Still needed" line below names what is actually
|
||||
// outstanding, which is the part a per-field hint cannot express.
|
||||
const sharedGroups = individuallyRequired
|
||||
? []
|
||||
: groups.filter(
|
||||
(names) => names.length > 1 && names.includes(varName),
|
||||
);
|
||||
const description = !required
|
||||
? "Optional"
|
||||
: sharedGroups.length > 0
|
||||
? `Any one of${sharedGroups.length > 1 ? " each" : ""}: ${sharedGroups
|
||||
.map((names) => names.join(", "))
|
||||
.join("; ")}`
|
||||
: undefined;
|
||||
return useAutocomplete ? (
|
||||
<Autocomplete
|
||||
key={varName}
|
||||
label={varName}
|
||||
description={description}
|
||||
placeholder={`Enter ${varName}`}
|
||||
value={fieldValue}
|
||||
data={completions[varName] ?? []}
|
||||
data={completionsFor(completions, varName)}
|
||||
// The server already filtered the values for the typed
|
||||
// prefix; passing options through verbatim avoids hiding
|
||||
// valid suggestions when the input is empty or doesn't
|
||||
|
|
@ -273,6 +360,7 @@ export function ResourceTemplatePanel({
|
|||
<TextInput
|
||||
key={varName}
|
||||
label={varName}
|
||||
description={description}
|
||||
placeholder={`Enter ${varName}`}
|
||||
value={fieldValue}
|
||||
onChange={(e) =>
|
||||
|
|
@ -290,6 +378,14 @@ export function ResourceTemplatePanel({
|
|||
);
|
||||
})}
|
||||
</Stack>
|
||||
{expansion.error !== undefined && (
|
||||
<ExpansionErrorText>{expansion.error}</ExpansionErrorText>
|
||||
)}
|
||||
{expansion.error === undefined && unmet.length > 0 && (
|
||||
<RequirementText>
|
||||
Still needed: {unmet.map((names) => names.join(" or ")).join("; ")}
|
||||
</RequirementText>
|
||||
)}
|
||||
<FooterRow>
|
||||
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
Read Resource
|
||||
|
|
|
|||
970
clients/web/src/test/core/mcp/uriTemplate.test.ts
Normal file
970
clients/web/src/test/core/mcp/uriTemplate.test.ts
Normal file
|
|
@ -0,0 +1,970 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
definedValues,
|
||||
expandUriTemplate,
|
||||
expandUriTemplateStrict,
|
||||
hasRequiredValues,
|
||||
parseUriTemplate,
|
||||
requiredGroups,
|
||||
templateVariables,
|
||||
templateError,
|
||||
tryExpandUriTemplate,
|
||||
unmetRequiredGroups,
|
||||
valueLengthError,
|
||||
} from "@inspector/core/mcp/uriTemplate.js";
|
||||
|
||||
describe("parseUriTemplate", () => {
|
||||
it("splits literals from expressions", () => {
|
||||
expect(parseUriTemplate("foobar://events/{topic}")).toEqual([
|
||||
{ kind: "literal", text: "foobar://events/" },
|
||||
{
|
||||
kind: "expression",
|
||||
source: "{topic}",
|
||||
operator: "",
|
||||
varspecs: [{ name: "topic" }],
|
||||
names: ["topic"],
|
||||
invalid: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the operator and the comma-separated name list", () => {
|
||||
expect(parseUriTemplate("x://{?a,b*}")).toEqual([
|
||||
{ kind: "literal", text: "x://" },
|
||||
{
|
||||
kind: "expression",
|
||||
source: "{?a,b*}",
|
||||
operator: "?",
|
||||
varspecs: [{ name: "a" }, { name: "b" }],
|
||||
names: ["a", "b"],
|
||||
invalid: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats an unclosed expression as trailing literal text", () => {
|
||||
expect(parseUriTemplate("x://a/{oops")).toEqual([
|
||||
{ kind: "literal", text: "x://a/{oops" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty template", () => {
|
||||
expect(parseUriTemplate("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("templateVariables", () => {
|
||||
it("finds a simple variable and marks it required", () => {
|
||||
expect(templateVariables("foobar://events/{topic}")).toEqual([
|
||||
{ name: "topic", operator: "", required: true, conforming: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => {
|
||||
expect(templateVariables("foobar://events{?topic}")).toEqual([
|
||||
{ name: "topic", operator: "?", required: false, conforming: true },
|
||||
]);
|
||||
});
|
||||
|
||||
// Required iff omitting the variable leaves an empty slot mid-URI rather
|
||||
// than a shorter, still-well-formed URI. Verified against the pinned SDK:
|
||||
// `x://a/{+path}` with no `path` expands to "x://a/" (empty segment), while
|
||||
// `x://a{#frag}` expands to exactly "x://a".
|
||||
it.each([
|
||||
["{+path}", "+", true],
|
||||
["{#frag}", "#", false],
|
||||
["{.label}", ".", false],
|
||||
["{/segment}", "/", false],
|
||||
["{&extra}", "&", false],
|
||||
])("classifies %s", (expression, operator, required) => {
|
||||
const [variable] = templateVariables(`x://a${expression}`);
|
||||
expect(variable.operator).toBe(operator);
|
||||
expect(variable.required).toBe(required);
|
||||
});
|
||||
|
||||
it("deduplicates a repeated name and keeps it required if any use is", () => {
|
||||
expect(templateVariables("x://{?id}/{id}")).toEqual([
|
||||
{ name: "id", operator: "?", required: true, conforming: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["file:///users/{userId}/profile", "file:///users//profile"],
|
||||
["x://a/{+path}", "x://a/"],
|
||||
])(
|
||||
"requires %s because omitting it leaves an empty slot (%s)",
|
||||
(template, omitted) => {
|
||||
expect(templateVariables(template)[0].required).toBe(true);
|
||||
// The reason, asserted rather than asserted-about: this is what the URI
|
||||
// would become if the field were left blank.
|
||||
expect(expandUriTemplate(template, {})).toBe(omitted);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not require {#frag}, which omits to a well-formed URI", () => {
|
||||
expect(templateVariables("x://a{#frag}")[0].required).toBe(false);
|
||||
expect(expandUriTemplate("x://a{#frag}", {})).toBe("x://a");
|
||||
});
|
||||
|
||||
it("returns an empty list for a template with no expressions", () => {
|
||||
expect(templateVariables("file:///static.txt")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandUriTemplate", () => {
|
||||
it("percent-encodes a reserved character in a simple variable (#1919)", () => {
|
||||
expect(
|
||||
expandUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
|
||||
).toBe("foobar://events/foo%2Fbar");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["?", "a?b", "a%3Fb"],
|
||||
["#", "a#b", "a%23b"],
|
||||
["%", "a%b", "a%25b"],
|
||||
["space", "a b", "a%20b"],
|
||||
["unicode", "caffè", "caff%C3%A8"],
|
||||
])("encodes %s", (_label, value, encoded) => {
|
||||
expect(expandUriTemplate("x://{v}", { v: value })).toBe(`x://${encoded}`);
|
||||
});
|
||||
|
||||
it("builds an encoded query expression", () => {
|
||||
expect(
|
||||
expandUriTemplate("foobar://events{?topic}", { topic: "foo/bar" }),
|
||||
).toBe("foobar://events?topic=foo%2Fbar");
|
||||
});
|
||||
|
||||
it("leaves reserved characters intact under the + operator", () => {
|
||||
expect(expandUriTemplate("x://{+path}", { path: "a/b" })).toBe("x://a/b");
|
||||
});
|
||||
|
||||
it("omits an expression whose variable is undefined", () => {
|
||||
expect(expandUriTemplate("foobar://events{?topic}", {})).toBe(
|
||||
"foobar://events",
|
||||
);
|
||||
// A form's untouched field arrives as "", which is a *defined* value; the
|
||||
// form drops it on the way in. See "a value defined as the empty string".
|
||||
expect(
|
||||
expandUriTemplate(
|
||||
"foobar://events{?topic}",
|
||||
definedValues({ topic: "" }),
|
||||
),
|
||||
).toBe("foobar://events");
|
||||
});
|
||||
|
||||
it("expands each query expression independently, per RFC 6570", () => {
|
||||
// NOT `?one=1&two=2`. The SDK rewrites the second `?` to `&`, but measured
|
||||
// against the pinned SDK its own matcher then rejects the result:
|
||||
// match("x?one=1&two=2") on `x{?one}{?two}` is null, while
|
||||
// match("x?one=1?two=2") returns both variables. A server wanting a
|
||||
// continuation advertises `{?one}{&two}` -- see the test below.
|
||||
expect(expandUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" })).toBe(
|
||||
"x://a?one=1?two=2",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits & only where the template asks for the continuation operator", () => {
|
||||
expect(expandUriTemplate("x://a{?one}{&two}", { one: "1", two: "2" })).toBe(
|
||||
"x://a?one=1&two=2",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the raw template when the SDK cannot parse it", () => {
|
||||
expect(expandUriTemplate("x://a/{oops", { oops: "v" })).toBe("x://a/{oops");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandUriTemplate - multi-name expressions (SDK correction)", () => {
|
||||
// The pinned SDK's `expandPart` takes an early `names.length > 1` branch that
|
||||
// raw-joins the values, skipping BOTH encodeValue and the operator prefix.
|
||||
// Measured directly: `x://{a,b}` -> "x://foo/bar,q", `x://a{/p,q}` ->
|
||||
// "x://ax y,z". These assert the corrected output.
|
||||
it("encodes each value in a simple multi-name expression", () => {
|
||||
expect(expandUriTemplate("x://{a,b}", { a: "foo/bar", b: "q" })).toBe(
|
||||
"x://foo%2Fbar,q",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the / operator prefix and separator, and encodes", () => {
|
||||
expect(expandUriTemplate("x://a{/p,q}", { p: "x y", q: "z" })).toBe(
|
||||
"x://a/x%20y/z",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the . operator prefix and separator", () => {
|
||||
expect(expandUriTemplate("x://a{.p,q}", { p: "x/y", q: "z" })).toBe(
|
||||
"x://a.x%2Fy.z",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the # prefix and leaves reserved characters under it", () => {
|
||||
expect(expandUriTemplate("x://a{#p,q}", { p: "x/y", q: "z" })).toBe(
|
||||
"x://a#x/y,z",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves reserved characters under the + operator", () => {
|
||||
expect(expandUriTemplate("x://{+a,b}", { a: "x/y", b: "z" })).toBe(
|
||||
"x://x/y,z",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops only the undefined names, keeping the rest", () => {
|
||||
expect(expandUriTemplate("x://a{/p,q}", { q: "z" })).toBe("x://a/z");
|
||||
});
|
||||
|
||||
it("omits the whole expression when no name has a value", () => {
|
||||
expect(expandUriTemplate("x://a{/p,q}", {})).toBe("x://a");
|
||||
});
|
||||
|
||||
// Naming, not delegation: a multi-name QUERY expression pairs each name with
|
||||
// its own value and joins with `&` (RFC 6570 §3.2.8), unlike the bare
|
||||
// comma-joined list every non-named operator above produces.
|
||||
it("pairs each name with its value in a multi-name query expression", () => {
|
||||
expect(expandUriTemplate("x://a{?p,q}", { p: "x/y", q: "z" })).toBe(
|
||||
"x://a?p=x%2Fy&q=z",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("varspec modifiers", () => {
|
||||
// The pinned SDK folds a `:length` modifier into the variable name --
|
||||
// `new UriTemplate("x://a/{id:3}").variableNames` is `["id:3"]`, and it
|
||||
// expands to "x://a/" -- so a form built on it would render a field the
|
||||
// user cannot usefully fill.
|
||||
it("parses a prefix modifier off the variable name", () => {
|
||||
expect(templateVariables("x://a/{id:3}")).toEqual([
|
||||
{ name: "id", operator: "", required: true, conforming: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates the value to the prefix length before encoding", () => {
|
||||
expect(expandUriTemplate("x://a/{id:3}", { id: "abcdef" })).toBe(
|
||||
"x://a/abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes what survives truncation", () => {
|
||||
expect(expandUriTemplate("x://a/{id:3}", { id: "a/bcdef" })).toBe(
|
||||
"x://a/a%2Fb",
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates by code point, never splitting an astral character", () => {
|
||||
// "\u{1F600}" is one code point but two UTF-16 units, so a naive
|
||||
// `slice(0, 1)` would emit a lone surrogate.
|
||||
expect(expandUriTemplate("x://{v:1}", { v: "\u{1F600}x" })).toBe(
|
||||
`x://${encodeURIComponent("\u{1F600}")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("strips the explode modifier from the name", () => {
|
||||
expect(templateVariables("x://{id*}")[0].name).toBe("id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ; (path-parameter) operator", () => {
|
||||
// Absent from the SDK's operator list entirely: it parses `{;id}` as a
|
||||
// variable named ";id" and expands to "".
|
||||
it("is recognised as an operator, not part of the name", () => {
|
||||
expect(templateVariables("x://a{;id}")).toEqual([
|
||||
{ name: "id", operator: ";", required: false, conforming: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("expands to a named path parameter", () => {
|
||||
expect(expandUriTemplate("x://a{;id}", { id: "7" })).toBe("x://a;id=7");
|
||||
});
|
||||
|
||||
it("repeats its separator per pair", () => {
|
||||
expect(expandUriTemplate("x://a{;a,b}", { a: "1", b: "2" })).toBe(
|
||||
"x://a;a=1;b=2",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes the value", () => {
|
||||
expect(expandUriTemplate("x://a{;p}", { p: "x/y" })).toBe("x://a;p=x%2Fy");
|
||||
});
|
||||
|
||||
it("omits cleanly when undefined", () => {
|
||||
expect(expandUriTemplate("x://a{;id}", {})).toBe("x://a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiredGroups / hasRequiredValues", () => {
|
||||
// A required *expression* is satisfied by any one of its names, because
|
||||
// RFC 6570 drops the undefined ones -- verified against the SDK:
|
||||
// `x://{a,b}` with only `a` expands to "x://only-a".
|
||||
it("accepts a multi-name expression with only one name filled", () => {
|
||||
const groups = requiredGroups("x://{a,b}");
|
||||
expect(groups).toEqual([["a", "b"]]);
|
||||
expect(hasRequiredValues(groups, { a: "only-a", b: "" })).toBe(true);
|
||||
expect(
|
||||
expandUriTemplate("x://{a,b}", definedValues({ a: "only-a", b: "" })),
|
||||
).toBe("x://only-a");
|
||||
});
|
||||
|
||||
it("rejects a multi-name expression with nothing filled", () => {
|
||||
expect(
|
||||
hasRequiredValues(requiredGroups("x://{a,b}"), { a: "", b: "" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still requires a lone required variable", () => {
|
||||
const groups = requiredGroups("file:///users/{userId}/profile");
|
||||
expect(hasRequiredValues(groups, { userId: "" })).toBe(false);
|
||||
expect(hasRequiredValues(groups, { userId: "alice" })).toBe(true);
|
||||
});
|
||||
|
||||
it("never blocks on an omittable expression", () => {
|
||||
expect(requiredGroups("foobar://events{?topic}")).toEqual([]);
|
||||
expect(
|
||||
hasRequiredValues(requiredGroups("foobar://events{?topic}"), {
|
||||
topic: "",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is satisfied by a template with no variables at all", () => {
|
||||
expect(hasRequiredValues(requiredGroups("file:///static.txt"), {})).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks a name that recurs under a different operator", () => {
|
||||
// A per-variable model keeping only the first occurrence's group would
|
||||
// mark both names required with singleton groups and refuse this input;
|
||||
// the SDK expands the same template with just `a` to "x?a=11".
|
||||
const groups = requiredGroups("x{?a}{?b}{a,b}");
|
||||
expect(groups).toEqual([["a", "b"]]);
|
||||
expect(hasRequiredValues(groups, { a: "1", b: "" })).toBe(true);
|
||||
expect(expandUriTemplate("x{?a}{?b}{a,b}", { a: "1" })).toBe("x?a=11");
|
||||
});
|
||||
|
||||
it("satisfies two required expressions sharing a name, from the others", () => {
|
||||
// `{a,b}{a,c}`: filling only b and c satisfies both groups. No
|
||||
// per-variable flag can express this, which is why groups are separate.
|
||||
const groups = requiredGroups("x://{a,b}{a,c}");
|
||||
expect(groups).toEqual([
|
||||
["a", "b"],
|
||||
["a", "c"],
|
||||
]);
|
||||
expect(hasRequiredValues(groups, { b: "B", c: "C" })).toBe(true);
|
||||
expect(hasRequiredValues(groups, { b: "B" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a name repeated inside one expression", () => {
|
||||
// `{a,a}` is one requirement named twice, not two names either of which
|
||||
// would do. Undeduplicated it read as a shared group everywhere downstream:
|
||||
// the TUI's `length === 1` test marked `a` optional while its submit guard
|
||||
// still refused a blank, and the web panel offered "Any one of: a, a".
|
||||
it("collapses to a single-name required group", () => {
|
||||
expect(requiredGroups("x://{a,a}")).toEqual([["a"]]);
|
||||
});
|
||||
|
||||
it("still expands both occurrences, which RFC 6570 requires", () => {
|
||||
expect(expandUriTemplate("x://{a,a}", { a: "1" })).toBe("x://1,1");
|
||||
});
|
||||
|
||||
it("leaves genuinely distinct names alone", () => {
|
||||
expect(requiredGroups("x://{a,b}")).toEqual([["a", "b"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expression independence", () => {
|
||||
it("does not rewrite a later query expression on the own-expansion path", () => {
|
||||
expect(
|
||||
expandUriTemplate("x://a{;k}{?one}{?two}", {
|
||||
k: "v",
|
||||
one: "1",
|
||||
two: "2",
|
||||
}),
|
||||
).toBe("x://a;k=v?one=1?two=2");
|
||||
});
|
||||
|
||||
it("omits an expression with no value without affecting its neighbours", () => {
|
||||
expect(
|
||||
expandUriTemplate("x://a{;k}{?one}{?two}", { k: "v", two: "2" }),
|
||||
).toBe("x://a;k=v?two=2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("allow-reserved encoding under + and #", () => {
|
||||
// The SDK uses `encodeURI` for these operators, which corrupts two classes
|
||||
// of value rather than merely over-escaping: measured, encodeURI("[::1]")
|
||||
// is "%5B::1%5D" and encodeURI("%2F") is "%252F".
|
||||
it.each(["+", "#"])("leaves reserved [ and ] intact under %s", (operator) => {
|
||||
const prefix = operator === "#" ? "#" : "";
|
||||
expect(expandUriTemplate(`x://{${operator}v}`, { v: "[::1]" })).toBe(
|
||||
`x://${prefix}[::1]`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["+", "#"])(
|
||||
"does not double-encode an existing pct-triplet under %s",
|
||||
(operator) => {
|
||||
const prefix = operator === "#" ? "#" : "";
|
||||
expect(expandUriTemplate(`x://{${operator}v}`, { v: "a%2Fb" })).toBe(
|
||||
`x://${prefix}a%2Fb`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("still encodes a lone % that is not a triplet", () => {
|
||||
expect(expandUriTemplate("x://{+v}", { v: "100%" })).toBe("x://100%25");
|
||||
});
|
||||
|
||||
it("still encodes characters outside the allowed set", () => {
|
||||
expect(expandUriTemplate("x://{+v}", { v: "a b" })).toBe("x://a%20b");
|
||||
});
|
||||
|
||||
it("encodes an astral character whole rather than as surrogates", () => {
|
||||
expect(expandUriTemplate("x://{+v}", { v: "\u{1F600}" })).toBe(
|
||||
`x://${encodeURIComponent("\u{1F600}")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the same encoding in a multi-name + expression", () => {
|
||||
expect(expandUriTemplate("x://{+a,b}", { a: "[::1]", b: "%2F" })).toBe(
|
||||
"x://[::1],%2F",
|
||||
);
|
||||
});
|
||||
|
||||
it("still percent-encodes reserved characters under the simple operator", () => {
|
||||
// Only + and # allow reserved through; the default path is unchanged.
|
||||
expect(expandUriTemplate("x://{v}", { v: "[::1]" })).toBe(
|
||||
"x://%5B%3A%3A1%5D",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreserved encoding under the non-reserved operators", () => {
|
||||
// `encodeURIComponent` leaves the sub-delims !'()* bare, but RFC 6570 only
|
||||
// allows *unreserved* characters through for these operators.
|
||||
it.each([
|
||||
["", "x://"],
|
||||
[".", "x://a."],
|
||||
["/", "x://a/"],
|
||||
])("encodes !'()* under the %s operator", (operator, prefix) => {
|
||||
const base = operator === "" ? "x://" : "x://a";
|
||||
expect(
|
||||
expandUriTemplate(`${base}{${operator}v}`, { v: "a!b'c(d)e*f" }),
|
||||
).toBe(`${prefix}a%21b%27c%28d%29e%2Af`);
|
||||
});
|
||||
|
||||
it("encodes them in a named (query) expression too", () => {
|
||||
expect(expandUriTemplate("x://a{?v}", { v: "a!b" })).toBe("x://a?v=a%21b");
|
||||
});
|
||||
|
||||
it("encodes them in a matrix expression too", () => {
|
||||
expect(expandUriTemplate("x://a{;v}", { v: "a!b" })).toBe("x://a;v=a%21b");
|
||||
});
|
||||
|
||||
it("leaves them alone under + and #, where reserved characters are allowed", () => {
|
||||
expect(expandUriTemplate("x://{+v}", { v: "a!b'c(d)e*f" })).toBe(
|
||||
"x://a!b'c(d)e*f",
|
||||
);
|
||||
});
|
||||
|
||||
it("still leaves the unreserved set itself untouched", () => {
|
||||
expect(expandUriTemplate("x://{v}", { v: "aZ0-._~" })).toBe("x://aZ0-._~");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prefix-modifier grammar", () => {
|
||||
// RFC 6570: max-length = %x31-39 0*3DIGIT -- 1..9999, no leading zero.
|
||||
// The SDK's constructor accepts these shapes, so nothing else rejects them;
|
||||
// treating `{id:abc}` as a plain `{id}` would send a URI the server never
|
||||
// advertised, with nothing to alert anyone.
|
||||
it.each(["x://{id:}", "x://{id:0}", "x://{id:abc}", "x://{id:10000}"])(
|
||||
"strict rejects the invalid template %s",
|
||||
(template) => {
|
||||
expect(() => expandUriTemplateStrict(template, { id: "abcdef" })).toThrow(
|
||||
/Invalid RFC 6570 varspec/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["x://{id:}", "x://{id:abc}"])(
|
||||
"lenient returns %s unchanged rather than guessing",
|
||||
(template) => {
|
||||
expect(expandUriTemplate(template, { id: "abcdef" })).toBe(template);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["x://{id:1}", "a"],
|
||||
["x://{id:9999}", "abcdef"],
|
||||
])("accepts the in-range modifier %s", (template, expected) => {
|
||||
expect(expandUriTemplate(template, { id: "abcdef" })).toBe(
|
||||
`x://${expected}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("variable names that collide with Object.prototype", () => {
|
||||
// `toString`, `constructor`, `valueOf` and `__proto__` are all valid RFC 6570
|
||||
// varnames. A bare `values[name]` lookup finds the prototype's member for
|
||||
// every one of them, so a *blank* field read as supplied: measured,
|
||||
// `({})["toString"] !== undefined` is true and its typeof is "function".
|
||||
it.each(["toString", "constructor", "valueOf", "hasOwnProperty"])(
|
||||
"reads a blank {?%s} as its own empty value, not a prototype member",
|
||||
(name) => {
|
||||
// The own value is "", so the expression expands to a valueless pair --
|
||||
// NOT the inherited function's body, which is what a bare lookup gave.
|
||||
expect(expandUriTemplate(`x://a{?${name}}`, { [name]: "" })).toBe(
|
||||
`x://a?${name}=`,
|
||||
);
|
||||
// And with the key absent, the expression is omitted entirely.
|
||||
expect(expandUriTemplate(`x://a{?${name}}`, {})).toBe("x://a");
|
||||
},
|
||||
);
|
||||
|
||||
it("omits the expression when the key is absent entirely", () => {
|
||||
expect(expandUriTemplate("x://a{?toString}", {})).toBe("x://a");
|
||||
});
|
||||
|
||||
it("still expands such a variable when it really has a value", () => {
|
||||
expect(expandUriTemplate("x://a{?toString}", { toString: "v" })).toBe(
|
||||
"x://a?toString=v",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat an inherited member as satisfying a required group", () => {
|
||||
// `Object` (the inherited constructor) has length 1, so the old
|
||||
// `(values[name] ?? "").length > 0` test reported this as satisfied.
|
||||
expect(hasRequiredValues([["constructor"]], {})).toBe(false);
|
||||
expect(hasRequiredValues([["constructor"]], { constructor: "c" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles __proto__ as an ordinary variable name", () => {
|
||||
expect(expandUriTemplate("x://a{?__proto__}", {})).toBe("x://a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("an expression that declares no variable", () => {
|
||||
// RFC 6570 requires at least one varspec per expression and admits no empty
|
||||
// member, so each of these is a malformed template rather than one with a
|
||||
// member to skip. Skipping is the dangerous reading: `x://{}` would expand to
|
||||
// `x://` while the form rendered no inputs, so its "everything required is
|
||||
// filled" check is vacuously true and it submits a URI that is not the
|
||||
// template the server published.
|
||||
it.each([
|
||||
["an empty expression", "x://{}"],
|
||||
["only a separator", "x://{,}"],
|
||||
["a missing member", "x://{a,}"],
|
||||
["an operator and no name", "x://a{?}"],
|
||||
// `*` is an explode modifier, not a name -- it is stripped before the
|
||||
// emptiness test, so this must be rejected the same way.
|
||||
["only an explode modifier", "x://{*}"],
|
||||
])("strict rejects %s", (_label, template) => {
|
||||
expect(() => expandUriTemplateStrict(template, { a: "1" })).toThrow(
|
||||
/Invalid RFC 6570 varspec/,
|
||||
);
|
||||
});
|
||||
|
||||
it("declares no required group for it, so only the expansion gate reports it", () => {
|
||||
// An empty group could never be satisfied, and the form's "any one of"
|
||||
// message built from it would name no fields -- the accurate reason is the
|
||||
// malformed template, which tryExpandUriTemplate gives.
|
||||
expect(requiredGroups("x://{}")).toEqual([]);
|
||||
expect(tryExpandUriTemplate("x://{}", {}).error).toMatch(
|
||||
/Invalid RFC 6570 varspec/,
|
||||
);
|
||||
});
|
||||
|
||||
it("still reports the names a partly-empty expression does declare", () => {
|
||||
expect(templateVariables("x://{a,}").map((v) => v.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("leaves the template unexpanded on the display path", () => {
|
||||
expect(expandUriTemplate("x://{}", {})).toBe("x://{}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("varspec grammar", () => {
|
||||
// Each of these expanded rather than being refused before the anchored
|
||||
// grammar landed -- measured on the previous commit: `{*id}` and `{ id }`
|
||||
// both yielded "x://abcdef", and `{id*:3}` silently truncated to "x://abc"
|
||||
// through a modifier combination RFC 6570 does not allow.
|
||||
it.each([
|
||||
["explode in the leading position", "x://{*id}"],
|
||||
["explode combined with a prefix", "x://{id*:3}"],
|
||||
["whitespace around the name", "x://{ id }"],
|
||||
["whitespace inside the name", "x://{a b}"],
|
||||
["a name outside the character set", "x://{a/b}"],
|
||||
])("strict rejects %s", (_label, template) => {
|
||||
expect(() =>
|
||||
expandUriTemplateStrict(template, {
|
||||
id: "abcdef",
|
||||
a: "1",
|
||||
"a/b": "1",
|
||||
"a b": "1",
|
||||
}),
|
||||
).toThrow(/Invalid RFC 6570 varspec/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// RFC 6570 varchar, plus the two RFC 3986 unreserved characters the set is
|
||||
// deliberately widened by -- a name is emitted verbatim by `?`/`&`/`;`, so
|
||||
// the rule is "needs no encoding", and `{user-id}` is common in the wild.
|
||||
["a plain name", "x://{id}", { id: "7" }, "x://7"],
|
||||
["a hyphen", "x://{user-id}", { "user-id": "7" }, "x://7"],
|
||||
["a tilde", "x://{a~b}", { "a~b": "7" }, "x://7"],
|
||||
["an underscore", "x://{a_b}", { a_b: "7" }, "x://7"],
|
||||
["a dot separator", "x://{a.b}", { "a.b": "7" }, "x://7"],
|
||||
["a pct-encoded varchar", "x://{a%2Fb}", { "a%2Fb": "7" }, "x://7"],
|
||||
["a trailing explode", "x://{id*}", { id: "7" }, "x://7"],
|
||||
["a trailing prefix", "x://{id:3}", { id: "abcdef" }, "x://abc"],
|
||||
])("accepts %s", (_label, template, values, expected) => {
|
||||
expect(expandUriTemplate(template, values)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["x://{a.}", "x://{a..b}"])(
|
||||
"rejects the misplaced dot in %s",
|
||||
(template) => {
|
||||
// varname = varchar *( ["."] varchar ) -- a dot separates, it cannot
|
||||
// trail or double.
|
||||
expect(() => expandUriTemplateStrict(template, {})).toThrow(
|
||||
/Invalid RFC 6570 varspec/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("reads a leading dot as the label operator, not a malformed name", () => {
|
||||
// `{.a}` is label expansion of `a` -- the one "misplaced dot" that is not
|
||||
// one, since the operator is stripped before the varspec is parsed.
|
||||
expect(expandUriTemplate("x://a{.ext}", { ext: "json" })).toBe(
|
||||
"x://a.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unmetRequiredGroups", () => {
|
||||
it("names the groups that are missing", () => {
|
||||
expect(unmetRequiredGroups([["a", "b"], ["c"]], { c: "1" })).toEqual([
|
||||
["a", "b"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("agrees with hasRequiredValues on a prototype-collision name", () => {
|
||||
// The TUI derived its "Missing required template variable(s)" list with its
|
||||
// own bare `values[name]` filter: `({})["constructor"]` is `Object`, whose
|
||||
// `.length` is 1, so the group read as satisfied and the message named no
|
||||
// field while the gate still blocked the submit.
|
||||
const groups = [["constructor"]];
|
||||
expect(hasRequiredValues(groups, {})).toBe(false);
|
||||
expect(unmetRequiredGroups(groups, {})).toEqual([["constructor"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("literal normalization", () => {
|
||||
// RFC 6570 3.1: a literal may contain non-ASCII, but expansion emits it
|
||||
// pct-encoded -- the conformance case `café/{var}` expands to
|
||||
// `caf%C3%A9/value`. Literals used to pass through verbatim, and since this
|
||||
// module replaced the SDK's expander for both clients, nothing else was
|
||||
// going to encode them. (Measured: the pinned SDK returns `café/value` too.)
|
||||
it("percent-encodes a non-ASCII literal", () => {
|
||||
expect(expandUriTemplate("café/{var}", { var: "value" })).toBe(
|
||||
"caf%C3%A9/value",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes a space in a literal", () => {
|
||||
expect(expandUriTemplate("a b/{v}", { v: "x" })).toBe("a%20b/x");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["URI delimiters", "x://a/b?c=d#e{?q}", { q: "1" }, "x://a/b?c=d#e?q=1"],
|
||||
["an existing pct-triplet", "a%20b/{v}", { v: "x" }, "a%20b/x"],
|
||||
])("preserves %s", (_label, template, values, expected) => {
|
||||
expect(expandUriTemplate(template, values)).toBe(expected);
|
||||
});
|
||||
|
||||
it("passes a brace through, so a malformed template stays legible", () => {
|
||||
// A brace reaches a literal only from a malformed template (an unclosed
|
||||
// `{` becomes trailing text). Encoding it to %7B would leave a string
|
||||
// resembling neither the template nor anything the server could match.
|
||||
expect(expandUriTemplate("x://a/{oops", { oops: "v" })).toBe("x://a/{oops");
|
||||
});
|
||||
});
|
||||
|
||||
describe("names outside RFC 6570 varchar", () => {
|
||||
// The conformance suite rejects `{~thing}` and `{default-graph-uri}`. The
|
||||
// Inspector expands them anyway -- real servers publish hyphenated names and
|
||||
// the SDK's matcher round-trips them -- but the tolerance is labelled rather
|
||||
// than folded into the grammar, so a caller wanting RFC-exact behavior can
|
||||
// refuse on the flag.
|
||||
it.each(["user-id", "a~b"])("expands the tolerated name %s", (name) => {
|
||||
expect(expandUriTemplate(`x://{${name}}`, { [name]: "7" })).toBe("x://7");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["default-graph-uri", false],
|
||||
["~thing", false],
|
||||
["plain_name", true],
|
||||
["a.b", true],
|
||||
["%61%62", true],
|
||||
])("marks %s conforming=%s", (name, conforming) => {
|
||||
expect(templateVariables(`x://{${name}}`)[0].conforming).toBe(conforming);
|
||||
});
|
||||
|
||||
it("marks a name non-conforming if any occurrence needed the tolerance", () => {
|
||||
expect(templateVariables("x://{user-id}{?user-id}")[0].conforming).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prefix modifier truncation", () => {
|
||||
// RFC 6570 2.4.1 counts characters, and says a pct-encoded triplet counts as
|
||||
// ONE. Truncating by code point cut the sixth triplet in half -- measured
|
||||
// before the fix, `{+v:5}` over "%61%62%63%64%65%66" produced "x%61%256",
|
||||
// because the orphaned "%6" was no longer a triplet and its `%` was then
|
||||
// (correctly) encoded. A byte the caller never asked for.
|
||||
it.each([
|
||||
["+", "x{+v:5}", "x%61%62%63%64%65"],
|
||||
["#", "x{#v:5}", "x#%61%62%63%64%65"],
|
||||
])("keeps pct-triplets whole under %s", (_op, template, expected) => {
|
||||
expect(expandUriTemplate(template, { v: "%61%62%63%64%65%66" })).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// The counting rule is operator-INDEPENDENT: a triplet is one character
|
||||
// wherever it appears. An earlier revision grouped only under `+`/`#`,
|
||||
// which made the same input mean two things and truncated `{v:1}` to a `%`
|
||||
// that was never a character of the value (it emitted "%25").
|
||||
["x/{v:1}", "x/%2561"],
|
||||
["x/{v:2}", "x/%2561%2562"],
|
||||
["x/{v:3}", "x/%2561%2562"],
|
||||
])(
|
||||
"counts a triplet as one character under a simple expansion (%s)",
|
||||
(template, expected) => {
|
||||
// What the operator decides is the *encoding* afterwards -- a simple
|
||||
// expansion still escapes the retained triplet's `%`, so `%61` -> `%2561`.
|
||||
expect(expandUriTemplate(template, { v: "%61%62" })).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
// A pct-encoded UTF-8 sequence is ONE character: `%C3%A9` is `é`. Counting
|
||||
// per triplet returned `%C3`, a lone lead byte decoding to nothing.
|
||||
["x{+v:1}", "%C3%A9x", "x%C3%A9"],
|
||||
["x{+v:2}", "%C3%A9x", "x%C3%A9x"],
|
||||
// Three-octet (`€`) and four-octet (an emoji) sequences, same rule.
|
||||
["x{+v:1}", "%E2%82%ACy", "x%E2%82%AC"],
|
||||
["x{+v:1}", "%F0%9F%98%80z", "x%F0%9F%98%80"],
|
||||
// A malformed sequence -- a lead byte with too few continuations -- falls
|
||||
// back to per-triplet counting rather than swallowing what follows.
|
||||
["x{+v:1}", "%C3x", "x%C3"],
|
||||
// A stray continuation byte is not a sequence start.
|
||||
["x{+v:1}", "%A9%C3%A9", "x%A9"],
|
||||
// RFC 3629 well-formedness, not just the length-announcing lead byte: an
|
||||
// overlong encoding, a UTF-16 surrogate, and a code point past U+10FFFF
|
||||
// are each refused the grouping and counted per triplet.
|
||||
["x{+v:1}", "%C0%80x", "x%C0"],
|
||||
["x{+v:1}", "%E0%80%80x", "x%E0"],
|
||||
["x{+v:1}", "%ED%A0%80x", "x%ED"],
|
||||
["x{+v:1}", "%F4%90%80%80x", "x%F4"],
|
||||
// The boundary cases that ARE well-formed stay whole.
|
||||
["x{+v:1}", "%E0%A0%80x", "x%E0%A0%80"],
|
||||
["x{+v:1}", "%ED%9F%BFx", "x%ED%9F%BF"],
|
||||
["x{+v:1}", "%F4%8F%BF%BFx", "x%F4%8F%BF%BF"],
|
||||
])(
|
||||
"keeps a multi-octet sequence whole (%s over %s)",
|
||||
(template, value, expected) => {
|
||||
expect(expandUriTemplate(template, { v: value })).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("counts a triplet as one character under a named operator too", () => {
|
||||
expect(expandUriTemplate("x{?v:1}", { v: "%61%62" })).toBe("x?v=%2561");
|
||||
});
|
||||
|
||||
it("truncates by code point, not UTF-16 code unit", () => {
|
||||
// An astral character is one character; `slice` would cut the surrogate
|
||||
// pair in half and yield a lone surrogate.
|
||||
expect(
|
||||
expandUriTemplate("x/{v:2}", { v: "\u{1F600}\u{1F601}\u{1F602}" }),
|
||||
).toBe("x/%F0%9F%98%80%F0%9F%98%81");
|
||||
expect(expandUriTemplate("x{+v:2}", { v: "\u{1F600}\u{1F601}" })).toBe(
|
||||
"x%F0%9F%98%80%F0%9F%98%81",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a value shorter than the prefix untouched", () => {
|
||||
expect(expandUriTemplate("x{+v:9}", { v: "%61%62" })).toBe("x%61%62");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a value defined as the empty string", () => {
|
||||
// RFC 6570 distinguishes an *undefined* variable (the expression is omitted)
|
||||
// from one defined as "". The expander used to collapse the two, which made
|
||||
// these URIs unrequestable through any caller -- `readResourceFromTemplate`
|
||||
// included. Dropping blanks is a *form* concern (a text input cannot express
|
||||
// "defined but empty"), so each client applies `definedValues` on its way in.
|
||||
it.each([
|
||||
["a query expression keeps the =", "x{?q}", "x?q="],
|
||||
["a continuation keeps the =", "x?a=1{&q}", "x?a=1&q="],
|
||||
// RFC 6570 3.2.7: the matrix operator drops the `=` for an empty value.
|
||||
["the matrix operator drops the =", "x{;q}", "x;q"],
|
||||
["a path segment expands to a bare separator", "x{/q}", "x/"],
|
||||
["a label expands to a bare separator", "x{.q}", "x."],
|
||||
["a simple expression contributes nothing visible", "x/{q}", "x/"],
|
||||
])("%s", (_label, template, expected) => {
|
||||
expect(expandUriTemplate(template, { q: "" })).toBe(expected);
|
||||
});
|
||||
|
||||
it("omits the expression only when the key is absent", () => {
|
||||
expect(expandUriTemplate("x{?q}", {})).toBe("x");
|
||||
expect(expandUriTemplate("x{;q}", {})).toBe("x");
|
||||
});
|
||||
|
||||
it("keeps a defined-but-empty name alongside a filled one", () => {
|
||||
expect(expandUriTemplate("x{?a,b}", { a: "", b: "2" })).toBe("x?a=&b=2");
|
||||
});
|
||||
|
||||
it("definedValues is what a form applies to drop its untouched fields", () => {
|
||||
expect(expandUriTemplate("x{?q}", definedValues({ q: "" }))).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unmatched braces", () => {
|
||||
// The SDK's constructor rejects an unclosed `{` but reads a stray `}` as
|
||||
// literal text, and this parser does the same -- so `x://a}` used to expand
|
||||
// to itself, a "URI" carrying a brace, with the panel enabling the read.
|
||||
it.each([
|
||||
["a stray closing brace", "x://a}"],
|
||||
["a closing brace after an expression", "x://{a}}"],
|
||||
["an unclosed opening brace", "x://a/{oops"],
|
||||
])("strict refuses %s", (_label, template) => {
|
||||
// The stray `}` is ours to catch; the unclosed `{` the SDK's constructor
|
||||
// already rejects ("Unclosed template expression"). Either way the read is
|
||||
// withheld, which is the property under test.
|
||||
expect(() => expandUriTemplateStrict(template, { a: "1" })).toThrow(
|
||||
/Unmatched brace|Unclosed template expression/,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves such a template unexpanded on the display path", () => {
|
||||
expect(expandUriTemplate("x://a}", {})).toBe("x://a}");
|
||||
expect(expandUriTemplate("x://a/{oops", {})).toBe("x://a/{oops");
|
||||
});
|
||||
|
||||
it("still accepts a well-formed expression", () => {
|
||||
expect(expandUriTemplate("x://{a}/b", { a: "1" })).toBe("x://1/b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the per-value length ceiling", () => {
|
||||
// Replacing `UriTemplate.expand` dropped the SDK's own 1,000,000-character
|
||||
// guard, leaving the allocation-heavy encoders unbounded for a pasted or
|
||||
// programmatically supplied value.
|
||||
it("refuses a value past the limit", () => {
|
||||
const result = tryExpandUriTemplate("x://{v}", {
|
||||
v: "a".repeat(1_000_001),
|
||||
});
|
||||
expect(result.uri).toBeUndefined();
|
||||
expect(result.error).toMatch(/exceeds the 1000000-character limit/);
|
||||
});
|
||||
|
||||
it("accepts a value at the limit", () => {
|
||||
const value = "a".repeat(1_000_000);
|
||||
expect(tryExpandUriTemplate("x://{v}", { v: value }).uri).toBe(
|
||||
`x://${value}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an oversized value the template never references", () => {
|
||||
// RFC 6570 ignores an extra variable, and the SDK path this replaced
|
||||
// validated a value only after looking one up by declared name -- so
|
||||
// checking the whole map refused `x://{id}` over a stale key beside it.
|
||||
expect(
|
||||
tryExpandUriTemplate("x://{id}", {
|
||||
id: "7",
|
||||
stale: "a".repeat(1_000_001),
|
||||
}).uri,
|
||||
).toBe("x://7");
|
||||
});
|
||||
|
||||
it("scopes valueLengthError to the names it is given", () => {
|
||||
const values = { id: "7", stale: "a".repeat(1_000_001) };
|
||||
expect(valueLengthError(values, ["id"])).toBeNull();
|
||||
expect(valueLengthError(values, ["id", "stale"])).not.toBeNull();
|
||||
// Unscoped, every entry is examined -- the behavior the preview and the
|
||||
// read both moved off.
|
||||
expect(valueLengthError(values)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("names the offending variable", () => {
|
||||
expect(
|
||||
tryExpandUriTemplate("x://{a}/{b}", { a: "1", b: "b".repeat(1_000_001) })
|
||||
.error,
|
||||
).toMatch(/"b"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("templateError", () => {
|
||||
it.each([
|
||||
["a stray closing brace", "x://{a}}"],
|
||||
["an unclosed brace", "x://{oops"],
|
||||
["an invalid varspec", "x://{a,}"],
|
||||
["an out-of-grammar modifier", "x://{id:abc}"],
|
||||
])("reports %s", (_label, template) => {
|
||||
expect(templateError(template)).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each(["x://{a}", "x://a{?b,c}", "x://a{;d:3}", "café/{var}"])(
|
||||
"passes the valid template %s",
|
||||
(template) => {
|
||||
expect(templateError(template)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("gives strict expansion its verdict, so the two cannot disagree", () => {
|
||||
// Every template the error reports must also refuse to expand.
|
||||
for (const template of ["x://{a}}", "x://{oops", "x://{a,}"]) {
|
||||
expect(templateError(template)).not.toBeNull();
|
||||
expect(() => expandUriTemplateStrict(template, { a: "1" })).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("valueLengthError", () => {
|
||||
it("names the first oversized value", () => {
|
||||
expect(valueLengthError({ a: "1", b: "b".repeat(1_000_001) })).toMatch(
|
||||
/"b" exceeds the 1000000-character limit/,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes values at or under the limit", () => {
|
||||
expect(valueLengthError({ a: "a".repeat(1_000_000) })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryExpandUriTemplate", () => {
|
||||
it("returns the URI and no error when the template expands", () => {
|
||||
expect(
|
||||
tryExpandUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
|
||||
).toEqual({ uri: "foobar://events/foo%2Fbar" });
|
||||
});
|
||||
|
||||
it("returns the reason instead of a URI for a malformed template", () => {
|
||||
const result = tryExpandUriTemplate("x://{id:abc}", { id: "7" });
|
||||
expect(result.uri).toBeUndefined();
|
||||
expect(result.error).toMatch(/Invalid RFC 6570 varspec/);
|
||||
});
|
||||
|
||||
it("reports a value that cannot be encoded rather than throwing", () => {
|
||||
// A lone surrogate has no UTF-8 encoding, so encodeURIComponent throws
|
||||
// URIError on it -- and a text input can hold one via paste.
|
||||
const result = tryExpandUriTemplate("x://{v}", { v: "\ud800" });
|
||||
expect(result.uri).toBeUndefined();
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
216
clients/web/src/test/integration/mcp/rfc6570-templates.test.ts
Normal file
216
clients/web/src/test/integration/mcp/rfc6570-templates.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||
import {
|
||||
definedValues,
|
||||
expandUriTemplate,
|
||||
tryExpandUriTemplate,
|
||||
} from "@inspector/core/mcp/uriTemplate.js";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
type TestServerHttp,
|
||||
createTestServerInfo,
|
||||
loadConfig,
|
||||
resolveConfig,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
|
||||
/**
|
||||
* Live coverage of `test-servers/configs/rfc6570-templates-http.json` — the
|
||||
* documented manual reproduction for #1919.
|
||||
*
|
||||
* The unit tests assert what `expandUriTemplate` *produces*. They cannot assert
|
||||
* that what it produces is what a spec-compliant server *accepts*, and that
|
||||
* second half is the entire bug: the old string substitution emitted a URI the
|
||||
* Inspector was perfectly happy with and the server rejected. So this drives
|
||||
* both directions over a real transport — the encoded URI must resolve, and the
|
||||
* unencoded one the old code produced must still be refused, so a regression
|
||||
* cannot pass by quietly loosening the server.
|
||||
*
|
||||
* The server is built by **resolving the checked-in config** rather than by
|
||||
* calling the fixture factories, which is the difference between covering the
|
||||
* wiring and merely asserting the factory: a misspelt preset name in
|
||||
* `preset-registry.ts`, or a config naming a preset that no longer exists,
|
||||
* fails here instead of only when someone runs the repro by hand.
|
||||
*/
|
||||
describe("RFC 6570 resource templates over the wire (#1919)", () => {
|
||||
let client: InspectorClient | null = null;
|
||||
let server: TestServerHttp | null = null;
|
||||
|
||||
const configPath = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../../test-servers/configs/rfc6570-templates-http.json",
|
||||
);
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
client = null;
|
||||
}
|
||||
if (server) {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
server = null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Boot the showcase config. The harness picks the port rather than using the
|
||||
* config's fixed one, so this cannot collide with a showcase server someone
|
||||
* is running by hand.
|
||||
*/
|
||||
async function connectToShowcase(): Promise<InspectorClient> {
|
||||
const resolved = resolveConfig(loadConfig(configPath));
|
||||
const started = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("rfc6570-templates-test", "1.0.0"),
|
||||
tools: resolved.tools,
|
||||
resources: resolved.resources,
|
||||
resourceTemplates: resolved.resourceTemplates,
|
||||
});
|
||||
await started.start();
|
||||
server = started;
|
||||
|
||||
const connected = new InspectorClient(
|
||||
{ type: "streamable-http", url: started.url },
|
||||
{ environment: { transport: createTransportNode } },
|
||||
);
|
||||
await connected.connect();
|
||||
client = connected;
|
||||
return connected;
|
||||
}
|
||||
|
||||
it("advertises every template from the checked-in config", async () => {
|
||||
const connected = await connectToShowcase();
|
||||
const { resourceTemplates } = await connected.listAllResourceTemplates();
|
||||
expect(resourceTemplates.map((entry) => entry.uriTemplate).sort()).toEqual([
|
||||
// The malformed one is advertised precisely so a client has to decide
|
||||
// what to do with it; the SDK's own constructor accepts it, so nothing
|
||||
// upstream of the Inspector rejects it. See the refusal test below.
|
||||
"foobar://events/{topic:abc}",
|
||||
"foobar://events/{topic}",
|
||||
"foobar://events{?topic}",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses to expand the malformed template rather than guessing", async () => {
|
||||
// `abc` is not RFC 6570's `max-length` production, so there is no URI to
|
||||
// send. Guessing `{topic}` would read something the server never
|
||||
// advertised -- and this server would answer it, which is exactly why the
|
||||
// check has to happen client-side.
|
||||
const result = tryExpandUriTemplate("foobar://events/{topic:abc}", {
|
||||
topic: "news",
|
||||
});
|
||||
expect(result.uri).toBeUndefined();
|
||||
expect(result.error).toMatch(/Invalid RFC 6570 varspec/);
|
||||
});
|
||||
|
||||
it("resolves the encoded URI the simple expression expands to", async () => {
|
||||
const connected = await connectToShowcase();
|
||||
const uri = expandUriTemplate("foobar://events/{topic}", {
|
||||
topic: "foo/bar",
|
||||
});
|
||||
expect(uri).toBe("foobar://events/foo%2Fbar");
|
||||
|
||||
const { result } = await connected.readResource(uri);
|
||||
expect(result.contents[0]?.uri).toBe(uri);
|
||||
});
|
||||
|
||||
it("rejects the unencoded URI the old string substitution produced", async () => {
|
||||
const connected = await connectToShowcase();
|
||||
// This is what the pre-fix client sent, and why #1919 was filed: the raw
|
||||
// `/` creates a second path segment that the template cannot match.
|
||||
await expect(
|
||||
connected.readResource("foobar://events/foo/bar"),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it("resolves the encoded URI the query expression expands to", async () => {
|
||||
const connected = await connectToShowcase();
|
||||
const uri = expandUriTemplate("foobar://events{?topic}", {
|
||||
topic: "foo/bar",
|
||||
});
|
||||
expect(uri).toBe("foobar://events?topic=foo%2Fbar");
|
||||
|
||||
const { result } = await connected.readResource(uri);
|
||||
expect(result.contents[0]?.uri).toBe(uri);
|
||||
});
|
||||
|
||||
// The tests above expand with the helper and then call `readResource`, which
|
||||
// leaves `InspectorClient.readResourceFromTemplate` -- the API the TUI drives
|
||||
// and the one this PR rewired -- unproven: they pass whether or not that
|
||||
// method still uses the SDK's expander. These drive it end to end with a
|
||||
// value that the two expanders disagree about, so reverting the wiring fails
|
||||
// here rather than silently.
|
||||
it("expands and reads a simple expression through readResourceFromTemplate", async () => {
|
||||
const connected = await connectToShowcase();
|
||||
const invocation = await connected.readResourceFromTemplate(
|
||||
"foobar://events/{topic}",
|
||||
{ topic: "foo/bar" },
|
||||
);
|
||||
expect(invocation.expandedUri).toBe("foobar://events/foo%2Fbar");
|
||||
expect(invocation.result.contents[0]?.uri).toBe(invocation.expandedUri);
|
||||
});
|
||||
|
||||
it("encodes a sub-delim the SDK's expander leaves bare", async () => {
|
||||
const connected = await connectToShowcase();
|
||||
// Verified by reverting the wiring: `encodeURIComponent` does encode `/`,
|
||||
// so `foo/bar` alone does NOT distinguish the two expanders -- the SDK
|
||||
// handles that case correctly and #1919 was the web panel's own string
|
||||
// substitution. `!` is where they differ: it is a sub-delim, which RFC 6570
|
||||
// requires encoded and `encodeURIComponent` leaves alone, so the SDK
|
||||
// expander returns `foobar://events/a!b` and fails this assertion.
|
||||
const invocation = await connected.readResourceFromTemplate(
|
||||
"foobar://events/{topic}",
|
||||
{ topic: "a!b" },
|
||||
);
|
||||
expect(invocation.expandedUri).toBe("foobar://events/a%21b");
|
||||
expect(invocation.result.contents[0]?.uri).toBe(invocation.expandedUri);
|
||||
});
|
||||
|
||||
it("expands and reads a query expression through readResourceFromTemplate", async () => {
|
||||
const connected = await connectToShowcase();
|
||||
const invocation = await connected.readResourceFromTemplate(
|
||||
"foobar://events{?topic}",
|
||||
{ topic: "foo/bar" },
|
||||
);
|
||||
expect(invocation.expandedUri).toBe("foobar://events?topic=foo%2Fbar");
|
||||
expect(invocation.result.contents[0]?.uri).toBe(invocation.expandedUri);
|
||||
});
|
||||
|
||||
it("refuses the malformed template through readResourceFromTemplate", async () => {
|
||||
const connected = await connectToShowcase();
|
||||
await expect(
|
||||
connected.readResourceFromTemplate("foobar://events/{topic:abc}", {
|
||||
topic: "news",
|
||||
}),
|
||||
).rejects.toThrow(/Invalid RFC 6570 varspec/);
|
||||
});
|
||||
|
||||
it("resolves the base URI a blank query expression expands to", async () => {
|
||||
const connected = await connectToShowcase();
|
||||
// RFC 6570 drops the whole expression when the variable is *undefined*, so
|
||||
// an unfilled `topic` legitimately requests the unfiltered collection. The
|
||||
// form is what turns its untouched blank into "undefined" (`definedValues`)
|
||||
// — a key present with "" is a defined value and would expand to
|
||||
// `?topic=`, which is a different (and here, unregistered) resource. The
|
||||
// SDK's matcher compiles `{?topic}` to a *required* `\?topic=([^&]+)`, so a
|
||||
// template alone cannot serve this — the config registers the plain
|
||||
// resource, which is what a real server would do.
|
||||
const uri = expandUriTemplate(
|
||||
"foobar://events{?topic}",
|
||||
definedValues({ topic: "" }),
|
||||
);
|
||||
expect(uri).toBe("foobar://events");
|
||||
|
||||
const { result } = await connected.readResource(uri);
|
||||
expect(result.contents[0]?.uri).toBe(uri);
|
||||
});
|
||||
});
|
||||
160
clients/web/src/utils/uriTemplate.test.ts
Normal file
160
clients/web/src/utils/uriTemplate.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { previewUriTemplate } from "./uriTemplate";
|
||||
|
||||
describe("previewUriTemplate", () => {
|
||||
it("shows the template verbatim before anything is entered", () => {
|
||||
expect(previewUriTemplate("file:///users/{userId}/profile", {})).toBe(
|
||||
"file:///users/{userId}/profile",
|
||||
);
|
||||
});
|
||||
|
||||
it("substitutes only the expressions that are filled", () => {
|
||||
expect(
|
||||
previewUriTemplate("db://{tableName}/rows/{rowId}", {
|
||||
tableName: "users",
|
||||
rowId: "",
|
||||
}),
|
||||
).toBe("db://users/rows/{rowId}");
|
||||
});
|
||||
|
||||
it("encodes the filled values the same way expansion does", () => {
|
||||
expect(
|
||||
previewUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
|
||||
).toBe("foobar://events/foo%2Fbar");
|
||||
});
|
||||
|
||||
it("expands a partially-filled multi-name expression, as submitting would", () => {
|
||||
// RFC 6570 drops the undefined names rather than the whole expression, so
|
||||
// showing `{?one,two}` here would promise a URI the submit does not send.
|
||||
expect(previewUriTemplate("x://a{?one,two}", { one: "1" })).toBe(
|
||||
"x://a?one=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a multi-name expression whole while none of its names is filled", () => {
|
||||
expect(previewUriTemplate("x://a{?one,two}", {})).toBe("x://a{?one,two}");
|
||||
});
|
||||
|
||||
it("expands each query expression independently, matching the submit", () => {
|
||||
// Not `?one=1&two=2` -- see the expander's tests: the SDK's own matcher
|
||||
// rejects that for this template. The preview must show what will be sent.
|
||||
expect(
|
||||
previewUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" }),
|
||||
).toBe("x://a?one=1?two=2");
|
||||
});
|
||||
|
||||
it("restores a deferred expression that follows a resolved query expression", () => {
|
||||
expect(previewUriTemplate("x://a{?one}{?two}", { one: "1" })).toBe(
|
||||
"x://a?one=1{?two}",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the raw template when the SDK cannot parse it", () => {
|
||||
expect(previewUriTemplate("x://a/{oops", {})).toBe("x://a/{oops");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewUriTemplate - multi-name expressions", () => {
|
||||
it("applies the same correction the real expansion does", () => {
|
||||
// Must match expandUriTemplate("x://{a,b}", ...) exactly, or the preview
|
||||
// would promise a URI that submitting does not send.
|
||||
expect(previewUriTemplate("x://{a,b}", { a: "foo/bar", b: "q" })).toBe(
|
||||
"x://foo%2Fbar,q",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview with Object.prototype-colliding names", () => {
|
||||
it("leaves a blank {?toString} standing rather than treating it as filled", () => {
|
||||
// A bare `defined[name] !== undefined` finds Object.prototype.toString and
|
||||
// would expand the expression instead of showing the placeholder.
|
||||
expect(previewUriTemplate("x://a{?toString}", { toString: "" })).toBe(
|
||||
"x://a{?toString}",
|
||||
);
|
||||
});
|
||||
|
||||
it("expands it once it really has a value", () => {
|
||||
expect(previewUriTemplate("x://a{?toString}", { toString: "v" })).toBe(
|
||||
"x://a?toString=v",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewUriTemplate - literals", () => {
|
||||
// The preview promises the URI that submitting would send, so it applies the
|
||||
// same RFC 6570 3.1 literal encoding the wire does.
|
||||
it("percent-encodes a non-ASCII literal", () => {
|
||||
expect(previewUriTemplate("café/{var}", { var: "value" })).toBe(
|
||||
"caf%C3%A9/value",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes the literal even while an expression is still unfilled", () => {
|
||||
expect(previewUriTemplate("café/{var}", {})).toBe("caf%C3%A9/{var}");
|
||||
});
|
||||
|
||||
it("shows a template the read would refuse exactly as the server wrote it", () => {
|
||||
// Not even the literal is encoded: an unreadable template is displayed
|
||||
// verbatim rather than half-normalized into something never published.
|
||||
expect(previewUriTemplate("x://café/{oops", {})).toBe("x://café/{oops");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewUriTemplate - it must never throw or over-promise", () => {
|
||||
// Both of these were free when the preview expanded a rewritten template
|
||||
// through the lenient `expandUriTemplate`; assembling part by part has to
|
||||
// reinstate them explicitly.
|
||||
it("falls back to the raw template when a value cannot be encoded", () => {
|
||||
// An unpaired surrogate has no UTF-8 encoding, so `encodeURIComponent`
|
||||
// raises URIError -- and this runs during render, where an escaping throw
|
||||
// unmounts the panel rather than disabling its button. A paste delivers it.
|
||||
expect(previewUriTemplate("x://{v}", { v: "\ud800" })).toBe("x://{v}");
|
||||
});
|
||||
|
||||
it("leaves an invalid expression standing rather than expanding it", () => {
|
||||
// `{a,}` keeps `a` in its varspecs, so expanding it would preview a URI
|
||||
// for a template whose submission is refused outright.
|
||||
expect(previewUriTemplate("x://{a,}", { a: "1" })).toBe("x://{a,}");
|
||||
expect(previewUriTemplate("x://{id:abc}", { "id:abc": "1" })).toBe(
|
||||
"x://{id:abc}",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an invalid varspec", "x://{a,}/{b}"],
|
||||
["a stray closing brace", "x://{a}}/{b}"],
|
||||
])(
|
||||
"does not expand the valid expressions around %s either",
|
||||
(_label, template) => {
|
||||
// The refusal is per TEMPLATE, not per expression: every read of these
|
||||
// is refused, so previewing `x://{a,}/2` or `x://1}/2` would promise a
|
||||
// URI this form can never send. An earlier revision did exactly that.
|
||||
expect(previewUriTemplate(template, { a: "1", b: "2" })).toBe(template);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("previewUriTemplate - the value ceiling", () => {
|
||||
it("falls back before encoding an oversized value", () => {
|
||||
// Without this the guard was half a guard: the read was refused while the
|
||||
// preview still encoded the same value on every keystroke, so a large
|
||||
// paste froze the UI anyway.
|
||||
expect(previewUriTemplate("x://{v}", { v: "a".repeat(1_000_001) })).toBe(
|
||||
"x://{v}",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an oversized value the template never references", () => {
|
||||
expect(
|
||||
previewUriTemplate("x://{id}", {
|
||||
id: "7",
|
||||
stale: "a".repeat(1_000_001),
|
||||
}),
|
||||
).toBe("x://7");
|
||||
});
|
||||
|
||||
it("still previews a value at the limit", () => {
|
||||
const value = "a".repeat(1_000_000);
|
||||
expect(previewUriTemplate("x://{v}", { v: value })).toBe(`x://${value}`);
|
||||
});
|
||||
});
|
||||
115
clients/web/src/utils/uriTemplate.ts
Normal file
115
clients/web/src/utils/uriTemplate.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* The web Resources form's view of an RFC 6570 URI template (#1919).
|
||||
*
|
||||
* Parsing, variable classification, and expansion live in
|
||||
* `@inspector/core/mcp/uriTemplate.js` so the web form and the TUI cannot
|
||||
* disagree about what a template means -- they are re-exported here so the
|
||||
* panel has a single import. (The CLI is not a consumer: it has no template
|
||||
* form, and its `resources/read` passes an already-expanded `--uri` straight to
|
||||
* `readResource`.) What this module adds is the one piece that is purely a
|
||||
* display concern: the partially-expanded preview string.
|
||||
*/
|
||||
|
||||
import {
|
||||
declaredNames,
|
||||
definedValues,
|
||||
encodeLiteral,
|
||||
expandTemplateExpression,
|
||||
parseUriTemplate,
|
||||
templateError,
|
||||
valueLengthError,
|
||||
} from "@inspector/core/mcp/uriTemplate.js";
|
||||
|
||||
export {
|
||||
definedValues,
|
||||
encodeLiteral,
|
||||
expandUriTemplate,
|
||||
hasRequiredValues,
|
||||
parseUriTemplate,
|
||||
requiredGroups,
|
||||
templateError,
|
||||
templateVariables,
|
||||
tryExpandUriTemplate,
|
||||
unmetRequiredGroups,
|
||||
valueLengthError,
|
||||
} from "@inspector/core/mcp/uriTemplate.js";
|
||||
export type {
|
||||
TemplateExpansion,
|
||||
TemplatePart,
|
||||
TemplateVariable,
|
||||
VarSpec,
|
||||
} from "@inspector/core/mcp/uriTemplate.js";
|
||||
|
||||
/**
|
||||
* A partially-expanded template for display: an expression with at least one
|
||||
* value is expanded exactly as the wire would render it, and the rest are left
|
||||
* standing as written so the user can see what is still needed.
|
||||
*
|
||||
* Assembled part by part rather than by rewriting the template and re-parsing
|
||||
* it. The rewrite approach needed a placeholder to stand in for each unexpanded
|
||||
* expression, and a placeholder is exactly what cannot survive the round trip
|
||||
* now that literals are pct-encoded on expansion (RFC 6570 §3.1) -- whatever
|
||||
* token stood in for `{?topic}` would come back encoded and no longer match.
|
||||
* Going part by part removes the token, and with it the question of what text
|
||||
* could never collide with a server-supplied template.
|
||||
*
|
||||
* Each half still comes from the shared expander -- `encodeLiteral` for literal
|
||||
* runs, `expandTemplateExpression` for filled ones -- so the preview cannot
|
||||
* promise a URI that submitting would not send.
|
||||
*
|
||||
* Two guards keep that promise, both of them things the previous
|
||||
* expand-the-whole-template implementation got for free from the lenient
|
||||
* `expandUriTemplate` it called:
|
||||
*
|
||||
* - **A template the read would refuse is shown verbatim.** `{a,}` still
|
||||
* parses `a` into its varspecs, and `x://{a}}/{b}` parses two perfectly good
|
||||
* expressions around a stray `}`, so expanding either previewed a URI whose
|
||||
* submission is refused outright. `templateError` is the read's own verdict,
|
||||
* so consulting it first is what keeps the two from disagreeing -- and it
|
||||
* subsumes the narrower "skip the invalid part" rule this had at first,
|
||||
* which left the *rest* of such a template expanding.
|
||||
* - **The whole thing is wrapped.** This runs during render, where a throw
|
||||
* unmounts the panel instead of disabling its button, and encoding can throw
|
||||
* for real input: `encodeURIComponent` raises `URIError` on an unpaired
|
||||
* surrogate, which a paste can deliver. The panel already reports that case
|
||||
* through `tryExpandUriTemplate`; here it degrades to the raw template.
|
||||
*/
|
||||
export function previewUriTemplate(
|
||||
uriTemplate: string,
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
const defined = definedValues(values);
|
||||
|
||||
// The same verdict the read is gated on. Without it this expanded expression
|
||||
// by expression regardless: `x://{a}}/{b}` with both fields filled previewed
|
||||
// `x://1}/2`, a URI whose stray `}` makes every read refuse -- the preview
|
||||
// promising something the form it sits in can never send.
|
||||
if (templateError(uriTemplate) !== null) return uriTemplate;
|
||||
|
||||
const parts = parseUriTemplate(uriTemplate);
|
||||
|
||||
// The same ceiling `tryExpandUriTemplate` enforces for the read, scoped the
|
||||
// same way -- to the names this template references. Without it the guard was
|
||||
// half a guard: submission was refused while this path still handed the
|
||||
// oversized value to the encoders on every keystroke, so a large paste froze
|
||||
// the UI anyway. Checked before any encoding happens.
|
||||
if (valueLengthError(defined, declaredNames(parts)) !== null) {
|
||||
return uriTemplate;
|
||||
}
|
||||
|
||||
try {
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (part.kind === "literal") return encodeLiteral(part.text);
|
||||
// `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are
|
||||
// valid RFC 6570 variable names, and a plain lookup would find
|
||||
// `Object.prototype`'s member and treat a blank field as filled.
|
||||
return part.names.some((name) => Object.hasOwn(defined, name))
|
||||
? expandTemplateExpression(part, defined)
|
||||
: part.source;
|
||||
})
|
||||
.join("");
|
||||
} catch {
|
||||
return uriTemplate;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue