Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7775907
update UriTemplate implementation to handle optional or out-of-order …
mgyarmathy Nov 5, 2025
3634992
chore: add changeset
felixweinberger Mar 26, 2026
6fcd8c4
omit absent query params from match result
felixweinberger Mar 26, 2026
7e4fd95
docs: add migration note for UriTemplate query param handling
felixweinberger Mar 27, 2026
fde6754
fix(core): handle malformed percent-encoding in UriTemplate.match()
felixweinberger Mar 27, 2026
01bcc47
fix(core): handle literal ? in templates and fix incomplete * replace…
felixweinberger Mar 27, 2026
79d782e
docs: drop migration.md entry since fix is backported to v1.x
felixweinberger Mar 27, 2026
a3cc896
Merge branch 'main' into fweinberger/uri-template-query-params
felixweinberger Mar 27, 2026
7e25ee3
fix(core): add boundary assertion to hasLiteralQuery regex and addres…
felixweinberger Mar 27, 2026
fb65293
Merge branch 'fweinberger/uri-template-query-params' of github.com:mo…
felixweinberger Mar 27, 2026
2fc117c
Merge branch 'main' into fweinberger/uri-template-query-params
felixweinberger Mar 30, 2026
37d2534
Merge branch 'main' into fweinberger/uri-template-query-params
felixweinberger Mar 30, 2026
5e07261
Merge remote-tracking branch 'origin/main' into fweinberger/uri-templ…
felixweinberger Mar 31, 2026
8a9aefb
refactor(core): reject literal-? in templates at construction; fix fr…
felixweinberger Mar 31, 2026
a911de3
Merge branch 'main' into fweinberger/uri-template-query-params
felixweinberger Apr 1, 2026
585c5f7
fix: make ?/# pre-processing conditional on template operators; don't…
felixweinberger Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-uri-template-query-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/core': patch
---

Fix `UriTemplate.match()` to correctly handle optional, out-of-order, and URL-encoded query parameters. Previously, query parameters had to appear in the exact order specified in the template and omitted parameters would cause match failures. Omitted query parameters are now absent from the result (rather than set to `''`), so callers can use `vars.param ?? defaultValue`.
70 changes: 63 additions & 7 deletions packages/core/src/shared/uriTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
const MAX_TEMPLATE_EXPRESSIONS = 10_000;
const MAX_REGEX_LENGTH = 1_000_000; // 1MB

function safeDecode(s: string): string {
try {
return decodeURIComponent(s);
} catch {
return s;
}
}

export class UriTemplate {
/**
* Returns true if the given string contains any URI template expressions.
Expand Down Expand Up @@ -97,7 +105,7 @@
return expr
.slice(operator.length)
.split(',')
.map(name => name.replace('*', '').trim())
.map(name => name.replaceAll('*', '').trim())
.filter(name => name.length > 0);
}

Expand Down Expand Up @@ -254,12 +262,19 @@

match(uri: string): Variables | null {
UriTemplate.validateLength(uri, MAX_TEMPLATE_LENGTH, 'URI');

// Build regex pattern for path (non-query) parts
let pattern = '^';
const names: Array<{ name: string; exploded: boolean }> = [];
const queryParts: Array<{ name: string; exploded: boolean }> = [];

for (const part of this.parts) {
if (typeof part === 'string') {
pattern += this.escapeRegExp(part);
} else if (part.operator === '?' || part.operator === '&') {
for (const name of part.names) {
queryParts.push({ name, exploded: part.exploded });
}
} else {
const patterns = this.partToRegExp(part);
for (const { pattern: partPattern, name } of patterns) {
Expand All @@ -269,22 +284,63 @@
}
}

// Only strip the URI fragment when the template has no {#var} operator;
// otherwise the fragment is part of what the path regex must capture.
const hasHashOperator = this.parts.some(p => typeof p !== 'string' && p.operator === '#');
let working = uri;
if (!hasHashOperator) {
const hashIndex = working.indexOf('#');
if (hashIndex !== -1) working = working.slice(0, hashIndex);
}

Check failure on line 294 in packages/core/src/shared/uriTemplate.ts

View check run for this annotation

Claude / Claude Code Review

{+var} with fragment: hasHashOperator misses + operator

The fragment-stripping guard at line 289 only checks `p.operator === '#'`, but the `{+var}` (reserved expansion) operator also uses `(.+)` in `partToRegExp` and is RFC 6570-defined to pass reserved characters including `#` — so `'http://e.com{+rest}'.match('http://e.com/path#section')` returns `{ rest: '/path' }` instead of `{ rest: '/path#section' }`. The fix is to extend `hasHashOperator` to also include templates with `+` operators: `this.parts.some(p => typeof p \!== 'string' && (p.operator
Comment on lines +287 to +294
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The fragment-stripping guard at line 289 only checks p.operator === '#', but the {+var} (reserved expansion) operator also uses (.+) in partToRegExp and is RFC 6570-defined to pass reserved characters including # — so 'http://e.com{+rest}'.match('http://e.com/path#section') returns { rest: '/path' } instead of { rest: '/path#section' }. The fix is to extend hasHashOperator to also include templates with + operators: this.parts.some(p => typeof p \!== 'string' && (p.operator === '#' || p.operator === '+')).

Extended reasoning...

What the bug is

The match() method introduced in this PR adds a pre-processing step that strips the URI fragment before regex matching (lines 287–294). The guard condition hasHashOperator is supposed to skip this stripping when a {#var} operator is present, because the fragment is part of what the path regex must capture. However, the {+var} (reserved expansion) operator also generates the pattern (.+) in partToRegExp (via case '+': case '#': pattern = '(.+)') and is RFC 6570-defined to pass reserved characters through unencoded — including #. The check only looks at p.operator === '#' and never considers p.operator === '+'.

The specific code path

For template 'http://e.com{+rest}' and URI 'http://e.com/path#section':

  1. hasHashOperator = this.parts.some(p => typeof p \!== 'string' && p.operator === '#')false (only + operator present)
  2. Fragment IS stripped: hashIndex finds #, so working = 'http://e.com/path'
  3. queryParts.length === 0 (no ?/& operators) → no query split; pathPart = 'http://e.com/path'
  4. Path regex ^http://e\.com(.+)$ is matched against 'http://e.com/path' → captures '/path'
  5. Result: { rest: '/path' } — fragment silently dropped

Why existing code doesn't prevent it

Both + and # operators generate identical (.+) patterns in partToRegExp. The hasHashOperator guard was added to protect # captures from fragment stripping, but the symmetric case for + was overlooked. The check is simply incomplete.

Impact

This is a regression introduced by this PR. Before the PR, match() applied the regex to the full URI string, so (.+) correctly captured '/path#section'. After the PR, the fragment is stripped first, silently breaking round-trip fidelity for {+var} templates. The round-trip guarantee advertised by the PR (expand(match(uri)) === uri) fails: expand({ rest: '/path#section' }) yields 'http://e.com/path#section', but match('http://e.com/path#section') returns { rest: '/path' }, so a second expand produces 'http://e.com/path'.

How to fix

Extend hasHashOperator to also cover the + operator:

const hasReservedOrHashOperator = this.parts.some(
  p => typeof p \!== 'string' && (p.operator === '#' || p.operator === '+')
);

Step-by-step proof

  1. Template: 'http://e.com{+rest}', parsed parts: ['http://e.com', { operator: '+', names: ['rest'], ... }]
  2. hasHashOperator = false (the part's operator is '+', not '#')
  3. hashIndex = uri.indexOf('#') = 20working = 'http://e.com/path'
  4. queryParts.length = 0pathPart = 'http://e.com/path'
  5. Regex /^http:\/\/e\.com(.+)$/ matches 'http://e.com/path'match[1] = '/path'
  6. result = { rest: '/path' }'#section' is permanently lost


// Only split path/query when the template actually has {?..}/{&..}
// operators. Otherwise match the path regex against the full URI so
// {+var} can capture across '?' as it did before query-param support.
let pathPart = working;
let queryPart = '';
if (queryParts.length > 0) {
const queryIndex = working.indexOf('?');
if (queryIndex !== -1) {
pathPart = working.slice(0, queryIndex);
queryPart = working.slice(queryIndex + 1);
}
}

Check failure on line 307 in packages/core/src/shared/uriTemplate.ts

View check run for this annotation

Claude / Claude Code Review

{&} standalone operator: match() returns null when no preceding {?}

The new `match()` routes `{&}` operators to `queryParts` (same as `{?}`) and splits the URI at `?`, but a standalone `{&}` template (no preceding `{?}`) produces URIs with `&` but no `?` — so the split finds nothing and the path regex (which omits the `&` part) fails to match. For template `/path{&extra}`, `expand({extra: 'val'})` = `'/path&extra=val'`, but `match('/path&extra=val')` returns `null`. Before this PR, `partToRegExp` for `{&extra}` generated `\&extra=([^&]+)` in the path regex, whic

Check failure on line 307 in packages/core/src/shared/uriTemplate.ts

View check run for this annotation

Claude / Claude Code Review

Combined {?param}+{#var} template always returns null

When a template uses both {?param} and {#var} operators together (e.g., /path{?a}{#b}), match() always returns null for valid URIs like /path?a=1#section. The two new conditional preprocessing steps interact incorrectly: hasHashOperator=true prevents fragment stripping, but queryParts.length>0 then splits at '?', placing the fragment into queryPart ('a=1#section') and leaving pathPart='/path' which cannot satisfy the (.+) capture pattern that {#b} adds to the path regex. Additionally, even if ma
Comment on lines +299 to +307
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new match() routes {&} operators to queryParts (same as {?}) and splits the URI at ?, but a standalone {&} template (no preceding {?}) produces URIs with & but no ? — so the split finds nothing and the path regex (which omits the & part) fails to match. For template /path{&extra}, expand({extra: 'val'}) = '/path&extra=val', but match('/path&extra=val') returns null. Before this PR, partToRegExp for {&extra} generated \&extra=([^&]+) in the path regex, which matched correctly.

Extended reasoning...

What the bug is and how it manifests

The new match() implementation separates template parts into two buckets: path parts (used to build the regex) and query parts ({?}/{&} operators). For query parts, the URI is split at the first ? to extract the query string. However, RFC 6570's {&} (form-style query continuation) operator is specifically designed to add &key=val segments without a leading ?. When {&} is used standalone (no preceding {?} or literal ?), the expanded URI contains & but no ? delimiter.

The specific code path that triggers it

In match() at lines 274-280, part.operator === '&' routes the operator names into queryParts (same as {?}). At lines 302-307, queryParts.length > 0 causes the code to look for ? in the URI. For a standalone {&} template like /path{&extra}, the expanded URI /path&extra=val has no ?, so queryIndex = -1, pathPart = '/path&extra=val', queryPart = ''. The path regex is built as ^/path$ (the {&extra} part contributed nothing to the path regex, having been routed to queryParts). Matching '/path&extra=val' against ^/path$ fails and returns null.

Why existing code doesn't prevent it

Before this PR, partToRegExp was called for {&} operators and generated \&name=([^&]+) patterns added directly to the path regex. The full URI was matched against the full regex, so ^/path\&extra=([^&]+)$ correctly matched /path&extra=val. The new code short-circuits {?}/{&} before calling partToRegExp, routing them to queryParts instead — which only works when there is a ? to split at.

What the impact would be

Round-trip fidelity breaks: expand(match(uri)) \!== uri because match returns null for valid expanded URIs using standalone {&}. The expand side still works correctly (X{&var} expands to X&var=test per existing tests), making the asymmetry subtle. Any MCP resource template or client using {&} without a preceding {?} will silently fail to match.

How to fix it

Only route {&} operators to queryParts when the template also contains a {?} operator or a literal ? in a string segment (i.e., when there will actually be a ? in expanded URIs). Otherwise, fall back to calling partToRegExp for {&} parts (the pre-PR behavior), keeping them in the path regex where they can match the &key=val suffix directly.

Step-by-step proof

  1. Template: /path{&extra}, URI: /path&extra=val
  2. Parts loop: literal '/path'pattern = '^/path'; {&extra} → appended to queryParts = [{name:'extra'}]
  3. hasHashOperator = false; no # in URI → working = '/path&extra=val'
  4. queryParts.length > 0 → search for ? in '/path&extra=val'queryIndex = -1 (not found)
  5. pathPart = '/path&extra=val', queryPart = ''
  6. Final pattern = '^/path$'; '/path&extra=val'.match(/^\/path$/)null
  7. Returns null — regression from pre-PR behavior where ^/path\&extra=([^&]+)$ matched '/path&extra=val' and returned {extra: 'val'}

Comment on lines +287 to +307
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 When a template uses both {?param} and {#var} operators together (e.g., /path{?a}{#b}), match() always returns null for valid URIs like /path?a=1#section. The two new conditional preprocessing steps interact incorrectly: hasHashOperator=true prevents fragment stripping, but queryParts.length>0 then splits at '?', placing the fragment into queryPart ('a=1#section') and leaving pathPart='/path' which cannot satisfy the (.+) capture pattern that {#b} adds to the path regex. Additionally, even if matching succeeded, the fragment would leak into the query value for 'a'.

Extended reasoning...

What the bug is and how it manifests

When a template contains both a {?...} query operator and a {#var} fragment operator (e.g., /path{?a}{#b}), the match() method unconditionally returns null for any valid matching URI. This is caused by two new conditional preprocessing steps introduced by this PR that were designed independently but fail when combined.

The specific code path that triggers it

For template /path{?a}{#b} and URI /path?a=1#section:

  1. Parts loop: {?a} goes to queryParts; {#b} is sent to partToRegExp (operator # is not ?/&), which adds (.+) to pattern and b to names. Final path pattern: ^/path(.+)$.
  2. Fragment check: hasHashOperator = true, so the fragment is NOT stripped. working = /path?a=1#section.
  3. Query split: queryParts.length > 0, so split at ?: pathPart = /path, queryPart = a=1#section.
  4. Path match: /path matched against /^/path(.+)$/ returns null because (.+) requires at least one character after /path, but pathPart is exactly /path.

Result: null. Expected: {a: '1', b: '#section'}.

Why existing code does not prevent it

The hasHashOperator check was added to preserve the fragment for {#} capture; the queryParts.length > 0 check was added to split path from query for {?} parsing. Each works correctly in isolation. But when both operators appear in the same template, they interact destructively: fragment preservation keeps the fragment in working, but the query split then moves that fragment into queryPart rather than leaving it available for path regex capture by the {#} pattern.

Impact

Any URI template combining {?...} and {#...} operators always returns null from match(), even for perfectly valid matching URIs. Additionally, even if the path match were somehow forced to succeed, queryPart = a=1#section would assign value 1#section to variable a — meaning the fragment leaks into the query value.

How to fix

When both hasHashOperator and queryParts.length > 0 are true, the fragment must be separated before the ? split. One approach: always strip the fragment from working before splitting at ?, record the fragment separately, then pass it back to the path match when hasHashOperator is true (appending it to pathPart so the {#} pattern can capture it).

Step-by-step proof

  • Template: /path{?a}{#b}, URI: /path?a=1#section
  • After parts loop: pattern = '^/path(.+)$', names = [{name:'b'}], queryParts = [{name:'a'}]
  • hasHashOperator = true -> working = /path?a=1#section (no stripping)
  • queryParts.length > 0 -> split at ?: pathPart = /path, queryPart = a=1#section
  • /path.match(/^/path(.+)$/) -> null
  • match() returns null — BUG: should return {a: '1', b: '#section'}
  • No test in the test suite covers this combined {?}+{#} case


pattern += '$';
UriTemplate.validateLength(pattern, MAX_REGEX_LENGTH, 'Generated regex pattern');
const regex = new RegExp(pattern);
const match = uri.match(regex);

const match = pathPart.match(regex);
if (!match) return null;

const result: Variables = {};
for (const [i, name_] of names.entries()) {
const { name, exploded } = name_!;
const value = match[i + 1]!;
const cleanName = name.replace('*', '');

for (const [i, { name, exploded }] of names.entries()) {
const value = match[i + 1]!;
const cleanName = name.replaceAll('*', '');
result[cleanName] = exploded && value.includes(',') ? value.split(',') : value;
}
Comment on lines +317 to 321
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Path variable captures in match() are returned as raw percent-encoded strings (lines 317-321), while query parameters are decoded via safeDecode() (lines 329-330) — an asymmetry newly introduced by this PR. This breaks the round-trip fidelity goal the PR describes: expand({ q: 'hello world' }) for {+q} yields /hello%20world, but match('/hello%20world') returns { q: 'hello%20world' }, so a second expand() double-encodes to /hello%2520world. The fix is to apply safeDecode to path variable captures in the names loop at lines 317-321, mirroring the query param handling.

Extended reasoning...

What the bug is

This PR adds safeDecode() calls when parsing query parameters (lines 329-330) so that {?q} matching ?q=hello%20world returns { q: 'hello world' }. However, path variable captures at lines 317-321 are returned without any decoding — the raw regex capture group value is stored directly in result[cleanName]. This creates a new inconsistency between the two handling paths.

The specific code path

In match(), path variable captures go through the names loop with no decoding applied. Query params go through safeDecode() at lines 329-330 before being stored. The path variable loop has no equivalent decoding step.

Why existing code does not prevent it

Before this PR, neither path variables nor query params were decoded — the behavior was symmetrically raw. This PR adds decoding only for query params, introducing a split where one code path decodes and the other does not. The path variable loop at lines 317-321 was not updated to match.

Impact and round-trip failure

The PR description explicitly advertises URL-encoded values are decoded and round-trip fidelity (expand(match(uri)) === uri) as goals. These goals hold for query params but fail for path operators:

  1. expand({ q: 'hello world' }) with template {+q} yields /hello%20world (via encodeURI)
  2. match('/hello%20world') returns { q: 'hello%20world' } (NOT decoded — returns raw)
  3. expand({ q: 'hello%20world' }) with template {+q} yields /hello%2520world (double-encoded!)

This also affects the simple {var} operator: template /users/{id} matching /users/john%20doe returns { id: 'john%20doe' }, while {?id} matching ?id=john%20doe returns { id: 'john doe' }.

Addressing the refutation

One verifier argues this is pre-existing because path variable non-decoding predates the PR and lines 317-321 are unchanged. This is technically accurate, but the PR creates a new user-visible inconsistency: prior to this PR, both operator types behaved the same (both raw). After this PR, the behavior diverges. The PR's own stated goals imply users should expect decoding to work uniformly, making this worth flagging as part of this PR rather than as a separate issue.

Fix

Apply safeDecode to path variable values in the names loop at lines 317-321, similar to the query param handling at lines 329-330. This mirrors the query param decoding and satisfies the round-trip guarantee for all operator types.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path-variable decoding is pre-existing behavior (base code never decoded either) and out of scope for this query-param fix. Will track applying safeDecode() to path captures separately.


if (queryParts.length > 0) {
const queryParams = new Map<string, string>();
if (queryPart) {
for (const pair of queryPart.split('&')) {
const equalIndex = pair.indexOf('=');
if (equalIndex !== -1) {
const key = safeDecode(pair.slice(0, equalIndex));
const value = safeDecode(pair.slice(equalIndex + 1));
queryParams.set(key, value);
}
}
}

for (const { name, exploded } of queryParts) {
const cleanName = name.replaceAll('*', '');
const value = queryParams.get(cleanName);
if (value === undefined) continue;
result[cleanName] = exploded && value.includes(',') ? value.split(',') : value;
}

Check failure on line 341 in packages/core/src/shared/uriTemplate.ts

View check run for this annotation

Claude / Claude Code Review

Exploded query params {?tags*} incorrectly split on percent-decoded commas

The exploded query param handler decodes values via `safeDecode()` before checking for commas, so a percent-encoded comma (`%2C`) in a single array element gets turned into a literal comma and causes an incorrect split. For example, `expand({tags: ['a,b']})` with `{?tags*}` produces `?tags=a%2Cb`, but `match('/path?tags=a%2Cb')` returns `{tags: ['a', 'b']}` instead of `{tags: ['a,b']}`. Fix by splitting on commas in the raw encoded value first, then decoding each element individually.
Comment on lines +336 to +341
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The exploded query param handler decodes values via safeDecode() before checking for commas, so a percent-encoded comma (%2C) in a single array element gets turned into a literal comma and causes an incorrect split. For example, expand({tags: ['a,b']}) with {?tags*} produces ?tags=a%2Cb, but match('/path?tags=a%2Cb') returns {tags: ['a', 'b']} instead of {tags: ['a,b']}. Fix by splitting on commas in the raw encoded value first, then decoding each element individually.

Extended reasoning...

What the bug is and how it manifests

In match(), query parameter values are decoded via safeDecode() at line 330 and stored in the queryParams Map as decoded strings. Then at line 340, the exploded check exploded && value.includes(',') ? value.split(',') : value operates on the already-decoded value. For a single array element containing a literal comma (e.g., tag value "a,b"), encodeURIComponent encodes the comma to %2C during expansion, but safeDecode converts it back to , before the split check, incorrectly creating two array elements.

The specific code path

  1. Line 330 (in the query params parsing loop): const value = safeDecode(pair.slice(equalIndex + 1)); — this decodes the full value, including any %2C sequences into literal commas.
  2. Line 340 (in the queryParts processing loop): result[cleanName] = exploded && value.includes(',') ? value.split(',') : value; — this operates on the already-decoded string, so "a,b" (decoded from "a%2Cb") is split into ['a', 'b'].

Why existing code does not prevent it

Prior to this PR, neither path variable captures nor query parameters were decoded — both returned raw encoded strings. The comma-split check was always performed on the raw encoded string, where %2C would not trigger a split. This PR introduces safeDecode() for query parameters but reuses the existing post-decode comma-split check, creating a new bug: commas that were intentionally percent-encoded to be part of a single value are now treated as array delimiters.

What the impact would be

Any {?name*} or {&name*} template whose elements contain literal commas will fail round-trip fidelity. Specifically, expand(match(uri)) \!== uri for any URI where an exploded array element contains %2C. This is a regression introduced by this PR — the pre-PR behavior was at least consistent (always returning raw strings).

How to fix it

Split on literal commas in the raw encoded value first, then decode each element individually:

// Instead of:
const value = safeDecode(pair.slice(equalIndex + 1));
queryParams.set(key, value);
// ...later...
result[cleanName] = exploded && value.includes(',') ? value.split(',') : value;

// Do:
const rawValue = pair.slice(equalIndex + 1);
queryParams.set(key, rawValue); // store raw, not decoded
// ...later...
if (exploded && rawValue.includes(',')) {
    result[cleanName] = rawValue.split(',').map(safeDecode);
} else {
    result[cleanName] = safeDecode(rawValue);
}

Step-by-step proof

  1. Template: {?tags*}, value: {tags: ['a,b', 'c']}
  2. expand({tags: ['a,b', 'c']})?tags=a%2Cb,c (encodeURIComponent encodes the comma inside 'a,b' to %2C, the inter-element comma is left literal)
  3. In match('/path?tags=a%2Cb,c'):
    • pair = 'tags=a%2Cb,c'
    • safeDecode('a%2Cb,c') = 'a,b,c' (DECODED: now has two commas)
    • queryParams.set('tags', 'a,b,c')
    • exploded=true, 'a,b,c'.includes(',') = true
    • 'a,b,c'.split(',') = ['a', 'b', 'c'] — WRONG (should be ['a,b', 'c'])
  4. Correct approach: split 'a%2Cb,c' on literal commas → ['a%2Cb', 'c'], then decode each → ['a,b', 'c']

}

return result;
}
}
94 changes: 94 additions & 0 deletions packages/core/test/shared/uriTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,105 @@ describe('UriTemplate', () => {
expect(template.variableNames).toEqual(['q', 'page']);
});

it('should handle partial query parameter matches correctly', () => {
const template = new UriTemplate('/search{?q,page}');
const match = template.match('/search?q=test');
expect(match).toEqual({ q: 'test' });
expect(template.variableNames).toEqual(['q', 'page']);
});

it('should match multiple query parameters if provided in a different order', () => {
const template = new UriTemplate('/search{?q,page}');
const match = template.match('/search?page=1&q=test');
expect(match).toEqual({ q: 'test', page: '1' });
expect(template.variableNames).toEqual(['q', 'page']);
});

it('should still match if additional query parameters are provided', () => {
const template = new UriTemplate('/search{?q,page}');
const match = template.match('/search?q=test&page=1&sort=desc');
expect(match).toEqual({ q: 'test', page: '1' });
expect(template.variableNames).toEqual(['q', 'page']);
});

it('should match omitted query parameters', () => {
const template = new UriTemplate('/search{?q,page}');
const match = template.match('/search');
expect(match).toEqual({});
expect(template.variableNames).toEqual(['q', 'page']);
});

it('should distinguish absent from empty query parameters', () => {
const template = new UriTemplate('/search{?q,page}');
const match = template.match('/search?q=');
expect(match).toEqual({ q: '' });
});

it('should match nested path segments with query parameters', () => {
const template = new UriTemplate('/api/{version}/{resource}{?apiKey,q,p,sort}');
const match = template.match('/api/v1/users?apiKey=testkey&q=user');
expect(match).toEqual({
version: 'v1',
resource: 'users',
apiKey: 'testkey',
q: 'user'
});
expect(template.variableNames).toEqual(['version', 'resource', 'apiKey', 'q', 'p', 'sort']);
});

it('should handle partial matches correctly', () => {
const template = new UriTemplate('/users/{id}');
expect(template.match('/users/123/extra')).toBeNull();
expect(template.match('/users')).toBeNull();
});

it('should handle encoded query parameters', () => {
const template = new UriTemplate('/search{?q}');
const match = template.match('/search?q=hello%20world');
expect(match).toEqual({ q: 'hello world' });
expect(template.variableNames).toEqual(['q']);
});

it('should not throw on malformed percent-encoding in query parameters', () => {
const template = new UriTemplate('/search{?q}');
expect(template.match('/search?q=100%')).toEqual({ q: '100%' });
expect(template.match('/search?q=%ZZ')).toEqual({ q: '%ZZ' });
});

it('should not throw on literal ? in a string segment (expand-only usage)', () => {
expect(() => new UriTemplate('/path?fixed=1')).not.toThrow();
expect(() => new UriTemplate('http://e.com/?literal').expand({})).not.toThrow();
expect(new UriTemplate('http://e.com/?literal').expand({})).toBe('http://e.com/?literal');
});

it('should let {+var} capture across ? when template has no query operators', () => {
const template = new UriTemplate('http://e.com{+rest}');
expect(template.match('http://e.com/search?q=hello')).toEqual({ rest: '/search?q=hello' });
});

it('should let {#var} capture the fragment', () => {
const template = new UriTemplate('/page{#section}');
expect(template.match('/page#intro')).toEqual({ section: '#intro' });
});

it('should accept templates using {?param} for query parameters', () => {
// The supported way to express query parameters
const template = new UriTemplate('/path{?fixed}');
expect(template.match('/path?fixed=1')).toEqual({ fixed: '1' });
expect(template.match('/path')).toEqual({});
});

it('should strip fragments before matching query parameters', () => {
const template = new UriTemplate('/path{?a}');
expect(template.match('/path?a=1#frag')).toEqual({ a: '1' });
expect(template.match('/path?a=1&b=2#frag')).toEqual({ a: '1' });
});

it('should strip fragments when the URI has no query string', () => {
const template = new UriTemplate('/path{?a}');
expect(template.match('/path#frag')).toEqual({});
expect(template.match('/path')).toEqual({});
});
});

describe('security and edge cases', () => {
Expand Down
Loading