fix: encode literals per RFC 6570 3.1, and label the varname tolerance
Addresses Copilot's round-10 review on #2035. One inline comment, one
suppressed; both real.
1. Literals were copied through verbatim, but RFC 6570 3.1 requires them
pct-encoded on expansion -- the conformance case `café/{var}` expands to
`caf%C3%A9/value`. Measured on ef3f5e74 it produced `café/value`, raw
UTF-8 in the path; since this module replaced the SDK's expander for both
clients, nothing else was going to encode it. (The pinned SDK does not
either -- it returns `café/value` too -- so this is a gap we inherited
rather than introduced.)
`encodeLiteral` keeps reserved delimiters (a literal's `/`, `?` and `#`
are structure) and existing pct-triplets, and encodes the rest. A BRACE
passes through: it can only reach a literal from a malformed template,
where an unclosed `{` becomes trailing text, and `%7B` would leave a
string resembling neither the template nor anything the server can match.
This is also what removed the preview's placeholder machinery. No token
can survive a round trip now that literals are encoded, so the preview
assembles part by part instead -- `encodeLiteral` for literal runs,
`expandTemplateExpression` for filled expressions, `part.source` for the
rest. Both halves still come from the shared expander, so the preview
cannot promise a URI that submitting would not send, and the question of
what token could never collide with a server-supplied template is gone.
2. On the varname set I took the structural point rather than the
conclusion. `VARCHAR` is RFC-exact again; `-` and `~` moved into a
separately named `TOLERATED_VARCHAR` tier, and a name needing it parses
with `conforming: false`, surfaced on `TemplateVariable`.
So the grammar in the code says what the RFC says and the tolerance is
explicit and separately tested -- but the Inspector still expands
`{default-graph-uri}`, because real servers publish hyphenated names and
the SDK's matcher round-trips them. Refusing to read a resource that
demonstrably works is a worse failure for a debugging tool than expanding
a name the conformance suite would reject. A caller wanting RFC-exact
behavior refuses on the flag rather than re-deriving the grammar.
npm run ci passes.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
ef3f5e74d8
commit
2e06b369f4
5 changed files with 225 additions and 98 deletions
|
|
@ -265,6 +265,8 @@ The `;` and `:3` rows are the ones a user sees directly: on the SDK's parse the
|
||||||
|
|
||||||
A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`, `{a,}`, `{?}`) — **withholds the read** rather than sending something. Pick **events_malformed** (`foobar://events/{topic:abc}`) to see it: Read Resource is disabled, the reason is printed under the form, and the preview shows the template as the server declared it. The alternative is worse than it looks: `x://{}` would otherwise expand to `x://` with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published.
|
A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`, `{a,}`, `{?}`) — **withholds the read** rather than sending something. Pick **events_malformed** (`foobar://events/{topic:abc}`) to see it: Read Resource is disabled, the reason is printed under the form, and the preview shows the template as the server declared it. The alternative is worse than it looks: `x://{}` would otherwise expand to `x://` with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published.
|
||||||
|
|
||||||
|
Literals are pct-encoded on expansion too (RFC 6570 §3.1): `café/{var}` sends `caf%C3%A9/value`, not raw UTF-8 in the path — something the SDK's expander does not do either. And the *names* a template may use are RFC 6570's `varchar` plus a labelled tolerance for `-` and `~`: the conformance suite rejects `{default-graph-uri}`, but real servers publish such names and the SDK's matcher round-trips them, so the Inspector expands them and marks the variable `conforming: false` rather than refusing a resource that demonstrably works.
|
||||||
|
|
||||||
An **undefined** variable is what omits its expression — a variable defined as the empty string expands (`x{?q}` gives `x?q=`, `x{;q}` gives `x;q`, per RFC 6570 §3.2.7). The expander honors that distinction, so a caller such as `readResourceFromTemplate` can request either URI. Collapsing the two is a *form* concern, not a template one: both clients seed every declared variable with `""` and a text input cannot express "defined but empty", so each form drops its blanks (`definedValues`) on the way in.
|
An **undefined** variable is what omits its expression — a variable defined as the empty string expands (`x{?q}` gives `x?q=`, `x{;q}` gives `x;q`, per RFC 6570 §3.2.7). The expander honors that distinction, so a caller such as `readResourceFromTemplate` can request either URI. Collapsing the two is a *form* concern, not a template one: both clients seed every declared variable with `""` and a text input cannot express "defined but empty", so each form drops its blanks (`definedValues`) on the way in.
|
||||||
|
|
||||||
Requiredness is a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and a form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}` is satisfied by filling `b` and `c`).
|
Requiredness is a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and a form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}` is satisfied by filling `b` and `c`).
|
||||||
|
|
|
||||||
|
|
@ -54,13 +54,13 @@ describe("parseUriTemplate", () => {
|
||||||
describe("templateVariables", () => {
|
describe("templateVariables", () => {
|
||||||
it("finds a simple variable and marks it required", () => {
|
it("finds a simple variable and marks it required", () => {
|
||||||
expect(templateVariables("foobar://events/{topic}")).toEqual([
|
expect(templateVariables("foobar://events/{topic}")).toEqual([
|
||||||
{ name: "topic", operator: "", required: true },
|
{ name: "topic", operator: "", required: true, conforming: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => {
|
it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => {
|
||||||
expect(templateVariables("foobar://events{?topic}")).toEqual([
|
expect(templateVariables("foobar://events{?topic}")).toEqual([
|
||||||
{ name: "topic", operator: "?", required: false },
|
{ name: "topic", operator: "?", required: false, conforming: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -82,7 +82,7 @@ describe("templateVariables", () => {
|
||||||
|
|
||||||
it("deduplicates a repeated name and keeps it required if any use is", () => {
|
it("deduplicates a repeated name and keeps it required if any use is", () => {
|
||||||
expect(templateVariables("x://{?id}/{id}")).toEqual([
|
expect(templateVariables("x://{?id}/{id}")).toEqual([
|
||||||
{ name: "id", operator: "?", required: true },
|
{ name: "id", operator: "?", required: true, conforming: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -232,7 +232,7 @@ describe("varspec modifiers", () => {
|
||||||
// user cannot usefully fill.
|
// user cannot usefully fill.
|
||||||
it("parses a prefix modifier off the variable name", () => {
|
it("parses a prefix modifier off the variable name", () => {
|
||||||
expect(templateVariables("x://a/{id:3}")).toEqual([
|
expect(templateVariables("x://a/{id:3}")).toEqual([
|
||||||
{ name: "id", operator: "", required: true },
|
{ name: "id", operator: "", required: true, conforming: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -266,7 +266,7 @@ describe("the ; (path-parameter) operator", () => {
|
||||||
// variable named ";id" and expands to "".
|
// variable named ";id" and expands to "".
|
||||||
it("is recognised as an operator, not part of the name", () => {
|
it("is recognised as an operator, not part of the name", () => {
|
||||||
expect(templateVariables("x://a{;id}")).toEqual([
|
expect(templateVariables("x://a{;id}")).toEqual([
|
||||||
{ name: "id", operator: ";", required: false },
|
{ name: "id", operator: ";", required: false, conforming: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -641,6 +641,64 @@ describe("unmetRequiredGroups", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("prefix modifier truncation", () => {
|
||||||
// RFC 6570 2.4.1 counts characters, and says a pct-encoded triplet counts as
|
// 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
|
// ONE. Truncating by code point cut the sixth triplet in half -- measured
|
||||||
|
|
|
||||||
|
|
@ -79,3 +79,23 @@ describe("preview with Object.prototype-colliding names", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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("keeps a malformed template legible rather than encoding its brace", () => {
|
||||||
|
expect(previewUriTemplate("x://café/{oops", {})).toBe(
|
||||||
|
"x://caf%C3%A9/{oops",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,14 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
definedValues,
|
definedValues,
|
||||||
expandUriTemplate,
|
encodeLiteral,
|
||||||
|
expandTemplateExpression,
|
||||||
parseUriTemplate,
|
parseUriTemplate,
|
||||||
} from "@inspector/core/mcp/uriTemplate.js";
|
} from "@inspector/core/mcp/uriTemplate.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
definedValues,
|
definedValues,
|
||||||
|
encodeLiteral,
|
||||||
expandUriTemplate,
|
expandUriTemplate,
|
||||||
hasRequiredValues,
|
hasRequiredValues,
|
||||||
parseUriTemplate,
|
parseUriTemplate,
|
||||||
|
|
@ -33,56 +35,37 @@ export type {
|
||||||
} from "@inspector/core/mcp/uriTemplate.js";
|
} from "@inspector/core/mcp/uriTemplate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A placeholder standing in for an expression the user has not filled in yet.
|
* 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.
|
||||||
*
|
*
|
||||||
* `U+0000` cannot appear in a URI template, so a token built from it can never
|
* Assembled part by part rather than by rewriting the template and re-parsing
|
||||||
* collide with real template text; and because it is emitted as *literal* text
|
* it. The rewrite approach needed a placeholder to stand in for each unexpanded
|
||||||
* rather than as a variable value, expansion passes it through unencoded.
|
* 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
|
||||||
const deferredToken = (index: number) => `\u0000${index}\u0000`;
|
* 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.
|
||||||
* A partially-expanded template for display: expressions with at least one
|
|
||||||
* value are expanded exactly as `expandUriTemplate` would, and the rest are
|
|
||||||
* left standing as written so the user can see what is still needed.
|
|
||||||
*
|
*
|
||||||
* Unfilled expressions are swapped for an inert token and restored afterwards,
|
* Each half still comes from the shared expander -- `encodeLiteral` for literal
|
||||||
* and the rewritten template is expanded by `expandUriTemplate` itself. Routing
|
* runs, `expandTemplateExpression` for filled ones -- so the preview cannot
|
||||||
* it back through the real expander is what keeps the preview honest: it cannot
|
* promise a URI that submitting would not send.
|
||||||
* promise a URI that submitting would not send. (Expansion carries no
|
|
||||||
* cross-expression state — see `expandParts` — so this is a per-expression
|
|
||||||
* substitution, not a whole-template rewrite that some later pass depends on.)
|
|
||||||
*/
|
*/
|
||||||
export function previewUriTemplate(
|
export function previewUriTemplate(
|
||||||
uriTemplate: string,
|
uriTemplate: string,
|
||||||
values: Record<string, string>,
|
values: Record<string, string>,
|
||||||
): string {
|
): string {
|
||||||
const parts = parseUriTemplate(uriTemplate);
|
|
||||||
const deferred: string[] = [];
|
|
||||||
const defined = definedValues(values);
|
const defined = definedValues(values);
|
||||||
|
|
||||||
const rewritten = parts
|
return parseUriTemplate(uriTemplate)
|
||||||
.map((part) => {
|
.map((part) => {
|
||||||
if (part.kind === "literal") return part.text;
|
if (part.kind === "literal") return encodeLiteral(part.text);
|
||||||
// `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are
|
// `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are
|
||||||
// valid RFC 6570 variable names, and a plain lookup would find
|
// valid RFC 6570 variable names, and a plain lookup would find
|
||||||
// `Object.prototype`'s member and treat a blank field as filled.
|
// `Object.prototype`'s member and treat a blank field as filled.
|
||||||
if (part.names.some((name) => Object.hasOwn(defined, name))) {
|
return part.names.some((name) => Object.hasOwn(defined, name))
|
||||||
return part.source;
|
? expandTemplateExpression(part, defined)
|
||||||
}
|
: part.source;
|
||||||
deferred.push(part.source);
|
|
||||||
return deferredToken(deferred.length - 1);
|
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
const expanded = expandUriTemplate(rewritten, defined);
|
|
||||||
|
|
||||||
// Restored by exact-string replacement rather than by a pattern: a regex
|
|
||||||
// matching the token would have to embed U+0000 literally, which `eslint`
|
|
||||||
// rejects (`no-control-regex`) -- and each token's text is already known
|
|
||||||
// here, so there is nothing to match on.
|
|
||||||
return deferred.reduce(
|
|
||||||
(uri, source, index) => uri.replaceAll(deferredToken(index), source),
|
|
||||||
expanded,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,14 @@ const NAMED_OPERATORS = new Set([";", "?", "&"]);
|
||||||
/** A single variable reference inside an expression, e.g. `id` or `id:3`. */
|
/** A single variable reference inside an expression, e.g. `id` or `id:3`. */
|
||||||
export interface VarSpec {
|
export interface VarSpec {
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* Absent (i.e. conforming) unless the name needed the `-`/`~` tolerance to
|
||||||
|
* parse — see {@link TOLERATED_VARCHAR}. Present and `false` marks a name RFC
|
||||||
|
* 6570's own conformance suite would reject, which the Inspector expands
|
||||||
|
* anyway because real servers publish such names and the SDK's matcher
|
||||||
|
* accepts them.
|
||||||
|
*/
|
||||||
|
conforming?: false;
|
||||||
/**
|
/**
|
||||||
* The RFC 6570 prefix modifier (`{id:3}`), a maximum length in *characters*.
|
* The RFC 6570 prefix modifier (`{id:3}`), a maximum length in *characters*.
|
||||||
* Applied to the value before percent-encoding, per §3.2.1.
|
* Applied to the value before percent-encoding, per §3.2.1.
|
||||||
|
|
@ -107,6 +115,13 @@ export interface TemplateVariable {
|
||||||
name: string;
|
name: string;
|
||||||
/** The operator of the expression the variable was first seen in. */
|
/** The operator of the expression the variable was first seen in. */
|
||||||
operator: string;
|
operator: string;
|
||||||
|
/**
|
||||||
|
* `false` when the name is outside RFC 6570's `varchar` and only parsed via
|
||||||
|
* the documented `-`/`~` tolerance (see {@link TOLERATED_VARCHAR}). A caller
|
||||||
|
* wanting RFC-exact behavior refuses on this rather than re-deriving the
|
||||||
|
* grammar; the Inspector itself expands such a template.
|
||||||
|
*/
|
||||||
|
conforming: boolean;
|
||||||
/**
|
/**
|
||||||
* True when this variable appears in at least one expression that cannot be
|
* True when this variable appears in at least one expression that cannot be
|
||||||
* omitted without changing the URI's structure — see
|
* omitted without changing the URI's structure — see
|
||||||
|
|
@ -122,37 +137,51 @@ export interface TemplateVariable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One character of a variable's name.
|
* RFC 6570's `varchar`, exactly: `ALPHA / DIGIT / "_" / pct-encoded`.
|
||||||
*
|
*
|
||||||
* RFC 6570's `varchar` is `ALPHA / DIGIT / "_" / pct-encoded`. This admits two
|
* This is the *conformance* production, kept unwidened so the grammar in the
|
||||||
* more — `-` and `~` — and the widening is deliberate, on a rule the RFC's own
|
* code says what the RFC says. What the parser goes on to **accept** is one
|
||||||
* set is a subset of: **a name is the one thing expansion emits into the URI
|
* tier wider — see {@link TOLERATED_VARCHAR}.
|
||||||
* without encoding it** (the `;`, `?` and `&` operators write `name=value`), so
|
|
||||||
* a name must consist of characters that need no encoding. That is RFC 3986's
|
|
||||||
* *unreserved* set — `ALPHA / DIGIT / -._~` — plus pct-encoded.
|
|
||||||
*
|
|
||||||
* Strict `varchar` would refuse `{user-id}`, which real servers publish and
|
|
||||||
* which the SDK's own matcher round-trips (`match()` compiles the hyphen
|
|
||||||
* literally). Refusing to read a resource that demonstrably works is a worse
|
|
||||||
* outcome for a debugging tool than tolerating a name one character outside the
|
|
||||||
* production — whereas everything this *does* reject is a shape we would
|
|
||||||
* otherwise have to **guess** at, which is the line the rest of this module
|
|
||||||
* draws too.
|
|
||||||
*/
|
*/
|
||||||
const VARCHAR = String.raw`(?:[A-Za-z0-9_\-~]|%[0-9A-Fa-f]{2})`;
|
const VARCHAR = String.raw`(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2})`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two characters accepted in a name beyond `varchar`, as an explicitly
|
||||||
|
* separate tier rather than a widened grammar.
|
||||||
|
*
|
||||||
|
* `-` and `~` are RFC 3986 *unreserved*, so they satisfy the property that
|
||||||
|
* actually matters at expansion time: a name is the one thing emitted into the
|
||||||
|
* URI **without** encoding (`;`, `?` and `&` write `name=value`), and these two
|
||||||
|
* need none. RFC 6570's own conformance suite rejects `{~thing}` and
|
||||||
|
* `{default-graph-uri}`, so a template using them is non-conforming — but real
|
||||||
|
* servers publish hyphenated names, and the SDK's matcher round-trips them
|
||||||
|
* (`match()` compiles the hyphen literally). Refusing to read a resource that
|
||||||
|
* demonstrably works is a worse failure for a debugging tool than expanding a
|
||||||
|
* name one character outside the production.
|
||||||
|
*
|
||||||
|
* So the tolerance is *labelled* rather than hidden: a varspec matching only
|
||||||
|
* this tier is parsed with `conforming: false`, surfaced on
|
||||||
|
* {@link TemplateVariable}, and a caller that wants RFC-exact behavior can
|
||||||
|
* refuse on that flag without re-implementing the grammar.
|
||||||
|
*/
|
||||||
|
const TOLERATED_VARCHAR = String.raw`[-~]`;
|
||||||
|
|
||||||
|
/** `varname = varchar *( ["."] varchar )` — a dot separates, never leads or doubles. */
|
||||||
|
function varnameGrammar(varchar: string): string {
|
||||||
|
return `${varchar}(?:\\.?${varchar})*`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC 6570's `varspec`: a `varname` with at most one trailing modifier, either
|
* RFC 6570's `varspec`: a `varname` with at most one trailing modifier, either
|
||||||
* the explode `*` or a `:max-length`, never both.
|
* the explode `*` or a `:max-length`, never both.
|
||||||
*
|
*
|
||||||
* `varname = varchar *( ["."] varchar )`, so a `.` may separate name segments
|
* `max-length` is `%x31-39 0*3DIGIT` — 1 to 9999, no leading zero — so `{id:}`,
|
||||||
* but cannot lead, trail, or double. `max-length` is `%x31-39 0*3DIGIT` — 1 to
|
* `{id:0}`, `{id:abc}` and `{id:10000}` are invalid *templates*, not templates
|
||||||
* 9999, no leading zero — so `{id:}`, `{id:0}`, `{id:abc}` and `{id:10000}` are
|
* with an ignorable modifier.
|
||||||
* invalid *templates*, not templates with an ignorable modifier.
|
|
||||||
*
|
*
|
||||||
* Anchoring the whole varspec is what rejects the shapes a looser "strip the
|
* Anchoring the whole varspec is what rejects the shapes a looser "strip the
|
||||||
* `*`, split on `:`" pass waves through. Measured against this branch before
|
* `*`, split on `:`" pass waves through. Measured before it landed, each
|
||||||
* the change, each expanded rather than being refused:
|
* expanded rather than being refused:
|
||||||
*
|
*
|
||||||
* | Varspec | Was | Why it is invalid |
|
* | Varspec | Was | Why it is invalid |
|
||||||
* | --------- | -------------------- | ----------------------------------- |
|
* | --------- | -------------------- | ----------------------------------- |
|
||||||
|
|
@ -161,39 +190,24 @@ const VARCHAR = String.raw`(?:[A-Za-z0-9_\-~]|%[0-9A-Fa-f]{2})`;
|
||||||
* | `{ id }` | name `id` | whitespace is not a `varchar` |
|
* | `{ id }` | name `id` | whitespace is not a `varchar` |
|
||||||
* | `{a b}` | name `a b` | ditto — and it emits into the URI |
|
* | `{a b}` | name `a b` | ditto — and it emits into the URI |
|
||||||
*/
|
*/
|
||||||
const VARSPEC_GRAMMAR = new RegExp(
|
const MODIFIER = String.raw`(?:\*|:([1-9][0-9]{0,3}))?`;
|
||||||
`^(${VARCHAR}(?:\\.?${VARCHAR})*)(?:\\*|:([1-9][0-9]{0,3}))?$`,
|
const CONFORMING_VARSPEC = new RegExp(
|
||||||
|
`^(${varnameGrammar(VARCHAR)})${MODIFIER}$`,
|
||||||
|
);
|
||||||
|
const TOLERATED_VARSPEC = new RegExp(
|
||||||
|
`^(${varnameGrammar(`(?:${VARCHAR}|${TOLERATED_VARCHAR})`)})${MODIFIER}$`,
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses one varspec (`id`, `id*`, `id:3`) into a name and optional prefix,
|
|
||||||
* or `"invalid"` for anything {@link VARSPEC_GRAMMAR} does not admit.
|
|
||||||
*
|
|
||||||
* Three classes reach that verdict, none of them ignorable:
|
|
||||||
*
|
|
||||||
* - **A modifier outside the grammar** (`{id:abc}`), or two at once (`{id*:3}`),
|
|
||||||
* or one in the wrong position (`{*id}`). Treating any of them as a plain
|
|
||||||
* `{id}` would send a URI that does not match what the server advertised,
|
|
||||||
* with nothing to alert anyone — and `{id*:3}` silently *truncated* the value.
|
|
||||||
* - **A name outside the character set** (`{a b}`, `{a/b}`). A named operator
|
|
||||||
* emits the name verbatim, so this is the one place an unencodable character
|
|
||||||
* reaches the URI as structure.
|
|
||||||
* - **An empty varspec** — `{}`, `{,}`, `{a,}`, `{?}`, `{*}`. RFC 6570 requires
|
|
||||||
* at least one varspec per expression and admits no empty member, so this is
|
|
||||||
* a malformed *template*, not an expression with a member to skip. Skipping
|
|
||||||
* it is the more dangerous reading: `x://{}` would expand to `x://` while the
|
|
||||||
* form rendered no inputs, so its "everything required is filled" check would
|
|
||||||
* be vacuously true and it would submit a URI that is not the template the
|
|
||||||
* server published.
|
|
||||||
*
|
|
||||||
* The explode modifier parses but is not honored: it only changes how a list or
|
|
||||||
* map value is joined, and every value reaching this module is a single string.
|
|
||||||
*/
|
|
||||||
function parseVarSpec(raw: string): VarSpec | "invalid" {
|
function parseVarSpec(raw: string): VarSpec | "invalid" {
|
||||||
const match = VARSPEC_GRAMMAR.exec(raw);
|
const conforming = CONFORMING_VARSPEC.exec(raw);
|
||||||
|
const match = conforming ?? TOLERATED_VARSPEC.exec(raw);
|
||||||
if (match === null) return "invalid";
|
if (match === null) return "invalid";
|
||||||
const [, name, maxLength] = match;
|
const [, name, maxLength] = match;
|
||||||
return maxLength === undefined ? { name } : { name, maxLength: +maxLength };
|
return {
|
||||||
|
name,
|
||||||
|
...(maxLength === undefined ? {} : { maxLength: +maxLength }),
|
||||||
|
...(conforming === null ? { conforming: false } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -257,11 +271,20 @@ export function templateVariables(uriTemplate: string): TemplateVariable[] {
|
||||||
if (part.kind !== "expression") continue;
|
if (part.kind !== "expression") continue;
|
||||||
const required = !OMITTABLE_OPERATORS.has(part.operator);
|
const required = !OMITTABLE_OPERATORS.has(part.operator);
|
||||||
for (const name of part.names) {
|
for (const name of part.names) {
|
||||||
|
const conforming = !part.varspecs.some(
|
||||||
|
(spec) => spec.name === name && spec.conforming === false,
|
||||||
|
);
|
||||||
const existing = byName.get(name);
|
const existing = byName.get(name);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.required = existing.required || required;
|
existing.required = existing.required || required;
|
||||||
|
existing.conforming = existing.conforming && conforming;
|
||||||
} else {
|
} else {
|
||||||
byName.set(name, { name, operator: part.operator, required });
|
byName.set(name, {
|
||||||
|
name,
|
||||||
|
operator: part.operator,
|
||||||
|
required,
|
||||||
|
conforming,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -398,6 +421,37 @@ const ALLOW_RESERVED = /[^A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/gu;
|
||||||
* `%25`. The `u` flag makes the class match by code point, so an astral
|
* `%25`. The `u` flag makes the class match by code point, so an astral
|
||||||
* character is handed to `encodeURIComponent` whole rather than as surrogates.
|
* character is handed to `encodeURIComponent` whole rather than as surrogates.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Percent-encodes a template's **literal** text (RFC 6570 §3.1).
|
||||||
|
*
|
||||||
|
* A literal may legally contain non-ASCII (`ucschar`), but expansion must emit
|
||||||
|
* it pct-encoded: the conformance case `café/{var}` expands to
|
||||||
|
* `caf%C3%A9/value`. Literals used to be copied through verbatim, so such a
|
||||||
|
* template went out with raw UTF-8 in the path — and since this module replaced
|
||||||
|
* the SDK's expander for both clients, nothing else was going to encode it. (The
|
||||||
|
* pinned SDK does not either; measured, it also returns `café/value`.)
|
||||||
|
*
|
||||||
|
* The rule is the same one {@link encodeAllowReserved} implements — keep
|
||||||
|
* unreserved, keep reserved (a literal's `/`, `?` and `#` are *structure*, not
|
||||||
|
* data), keep an existing pct-triplet, encode everything else — with one
|
||||||
|
* exception: a brace passes through.
|
||||||
|
*
|
||||||
|
* A brace is not a legal literal character, so it reaches this function only
|
||||||
|
* from a **malformed** template, where {@link parseUriTemplate} treats an
|
||||||
|
* unclosed `{` as trailing text. That case degrades to "show the template as
|
||||||
|
* the server wrote it", and encoding the brace to `%7B` would make the
|
||||||
|
* degradation unreadable — a string resembling neither the template nor
|
||||||
|
* anything the server could match.
|
||||||
|
*/
|
||||||
|
export function encodeLiteral(text: string): string {
|
||||||
|
return text
|
||||||
|
.split(/([{}])/g)
|
||||||
|
.map((chunk, index) =>
|
||||||
|
index % 2 === 1 ? chunk : encodeAllowReserved(chunk),
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function encodeAllowReserved(value: string): string {
|
function encodeAllowReserved(value: string): string {
|
||||||
return value
|
return value
|
||||||
.split(/(%[0-9A-Fa-f]{2})/g)
|
.split(/(%[0-9A-Fa-f]{2})/g)
|
||||||
|
|
@ -487,8 +541,16 @@ function renderValue(value: string, spec: VarSpec, operator: string): string {
|
||||||
return encodeValue(truncated, operator);
|
return encodeValue(truncated, operator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expands one expression per RFC 6570. Returns "" if no name has a value. */
|
/**
|
||||||
function expandExpression(
|
* Expands one expression per RFC 6570. Returns "" if no name has a value.
|
||||||
|
*
|
||||||
|
* Exported for the web client's URI *preview*, which expands the expressions
|
||||||
|
* the user has filled while leaving the rest standing as written — it cannot
|
||||||
|
* go through {@link expandParts}, which expands all or nothing, and routing a
|
||||||
|
* half-rewritten template string back through the parser would mean smuggling
|
||||||
|
* a placeholder past {@link encodeLiteral}.
|
||||||
|
*/
|
||||||
|
export function expandTemplateExpression(
|
||||||
part: TemplateExpression,
|
part: TemplateExpression,
|
||||||
values: Record<string, string>,
|
values: Record<string, string>,
|
||||||
): string {
|
): string {
|
||||||
|
|
@ -561,7 +623,9 @@ export function expandParts(
|
||||||
): string {
|
): string {
|
||||||
return parts
|
return parts
|
||||||
.map((part) =>
|
.map((part) =>
|
||||||
part.kind === "literal" ? part.text : expandExpression(part, values),
|
part.kind === "literal"
|
||||||
|
? encodeLiteral(part.text)
|
||||||
|
: expandTemplateExpression(part, values),
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue