fix(core): support ; and :length, and scope requiredness to the expression

Addresses Copilot's round-2 review on #2035. It reported "no new comments"
but carried three *suppressed* ones; all three reproduced against the
pinned SDK.

1. The `;` path-parameter operator is absent from the SDK's operator list,
   so `{;id}` parsed as a variable literally named ";id" and expanded to
   nothing. Added the operator and its named expansion (`;a=1;b=2`).

2. An RFC 6570 prefix modifier was folded into the variable name: `{id:3}`
   yielded a variable called "id:3" and expanded to nothing. Varspecs are
   now parsed properly and the value truncated before encoding. Truncation
   is by code point, since `String.prototype.slice` counts UTF-16 units and
   would split an astral character into a lone surrogate.

   For both of these the wrong URI is the lesser problem: a form has to
   *name* the variables it asks the user to fill, so the panel was
   rendering fields labelled `;id` and `id:3` that nobody could use.

3. Requiredness was applied per variable, but it is a property of the
   *expression*: RFC 6570 drops undefined names from a multi-name
   expression, so `{a,b}` with only `a` filled expands to `a`'s value --
   the SDK does this too. The panel was refusing input the expander would
   have accepted. `hasRequiredValues` now encodes "any one name in a
   required group suffices", and such a field reads "Any one of: a, b"
   rather than falsely claiming each is mandatory.

Two structural consequences, each pinned by a test:

- Takeover is now per TEMPLATE rather than per expression. Splicing
  corrected fragments into a template the SDK re-expands would leave its
  cross-expression `?`-to-`&` rewrite blind to the fragments already
  resolved.

- Expansion is split into a strict variant that throws and a lenient one
  that returns the raw template. `readResourceFromTemplate` wraps the
  thrown error with the template name -- three pre-existing integration
  tests assert that -- while the form must not throw on a server-supplied
  template, since that would take out the panel on render. The strict
  variant constructs the SDK template unconditionally, because that
  construction is what validates syntax: otherwise `x://{;a}{b,c` would
  take the own-expansion path and its unclosed tail would pass as literal
  text with nothing objecting.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 19:17:05 -04:00
commit 25c3ad272e

View file

@ -16,6 +16,7 @@ import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"
import { CopyButton } from "../../elements/CopyButton/CopyButton";
import {
expandUriTemplate,
hasRequiredValues,
previewUriTemplate,
templateVariables,
} from "../../../utils/uriTemplate";
@ -215,12 +216,12 @@ export function ResourceTemplatePanel({
void runCompletion(varName, value, buildContext(varName));
}
// Only the variables whose absence would change the URI's shape gate the
// Only the expressions whose absence would change the URI's shape gate the
// read; an unfilled `{?topic}` is a legitimate request for the unfiltered
// resource, and RFC 6570 drops the whole expression for it.
const canSubmit = declaredVariables.every(
(v) => !v.required || variables[v.name]?.length > 0,
);
// 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.
const canSubmit = hasRequiredValues(declaredVariables, variables);
function handleSubmit() {
onReadResource(expandUriTemplate(uriTemplate, variables));
@ -239,12 +240,19 @@ export function ResourceTemplatePanel({
</HeaderRow>
{description && <DescriptionText>{description}</DescriptionText>}
<Stack gap="sm">
{declaredVariables.map(({ name: varName, required }) => {
{declaredVariables.map(({ name: varName, required, groupNames }) => {
/* 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.
const description = required ? undefined : "Optional";
// 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.
const description = !required
? "Optional"
: groupNames.length > 1
? `Any one of: ${groupNames.join(", ")}`
: undefined;
return useAutocomplete ? (
<Autocomplete
key={varName}