[v1.x] fix: Update UriTemplate implementation to handle optional/omitted, out-of-order, and encoded query parameters#1083
Conversation
5e2623c to
5950c61
Compare
5950c61 to
ee8c7f6
Compare
commit: |
c7b3331 to
6e5ff20
Compare
6e5ff20 to
6ec58ad
Compare
|
Team - any chance of revisiting this PR? - Not being able to use optional query parameters is kinda limiting. |
|
I've updated the base branch for this so that the bugfix is backported to |
travisbreaks
left a comment
There was a problem hiding this comment.
Clean implementation. Separating path matching from query parameter extraction is the right approach for RFC 6570 query templates. The code correctly handles all the tricky cases: optional params, out-of-order params, URL-encoded values, and extra query params.
Test coverage is solid: 6 new test cases covering partial matches, reordered params, extra params, omitted params, nested paths with query params, and encoded values.
A few observations:
1. Missing query params default to empty string
if (value === undefined) {
result[cleanName] = '';
}An omitted optional query param returning '' (empty string) rather than undefined could be surprising to consumers. With undefined, callers can distinguish "param was omitted" from "param was explicitly set to empty." Consider returning undefined for truly absent params. The tests currently assert '', so this would be a design decision for the maintainers.
2. Extra query params silently ignored
The test "should still match if additional query parameters are provided" confirms that sort=desc is silently dropped. This is correct per RFC 6570 (the template only declares q and page), but worth a brief comment in the code for future readers.
3. Duplicate query param keys
If a URI has ?q=foo&q=bar, the Map will keep only the last value (bar). RFC 6570 does not define behavior for duplicate keys in query matching, so this is reasonable, but worth noting.
4. v2 port (PR #1396)
This PR targets v1.x. PR #1396 ports the same fix to v2 but has merge conflicts. If this lands on v1.x, the v2 port should be straightforward to rebase.
This has been open since October with no review. It addresses a real bug with a clean fix and good tests. Would be great to see it merged.
felixweinberger
left a comment
There was a problem hiding this comment.
The v2 version of this fix is up at #1785 with one change: absent query params are omitted from the result rather than set to '', so expand(match(uri)) round-trips correctly. Could you take a look and apply the same change here? Feedback on the approach welcome.
Absent query parameters are now omitted from the result object rather
than set to empty string, so callers can use `vars.param ?? default`.
This also distinguishes 'param absent' from 'param present but empty'
(e.g. ?q= returns {q: ''}).
Also removes dead code: the isQuery field was always false since query
parts go to a separate array, and the (?:\\?.*)?$ regex suffix was
unreachable since pathPart already excludes the query string.
6ec58ad to
0924abb
Compare
🦋 Changeset detectedLatest commit: 1fbe148 The changes in this PR will be included in the next version bump. Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
@felixweinberger I've applied the same changes as #1785 -- everything included there looks more clean and safe. |
|
@claude review |
src/shared/uriTemplate.ts
Outdated
| // Split URI into path and query parts | ||
| const queryIndex = uri.indexOf('?'); | ||
| const pathPart = queryIndex === -1 ? uri : uri.slice(0, queryIndex); | ||
| const queryPart = queryIndex === -1 ? '' : uri.slice(queryIndex + 1); |
There was a problem hiding this comment.
🔴 The new match() unconditionally splits the URI at the first ? (line 260) to separate path from query, which breaks templates using the {&var} (form-continuation) operator. For example, /search?static=true{&q} fails to match /search?static=true&q=test because pathPart becomes /search while the path regex expects ^/search\?static\=true$. This is a regression from previously working behavior where the regex was applied to the full URI.
Extended reasoning...
What the bug is
The match() method introduced a URI-splitting strategy at line 260 that unconditionally splits the input URI at the first ? character. The path portion (before ?) is matched against a regex built from non-query template parts, while query parameters (after ?) are parsed separately with a Map. This breaks any template that contains a literal ? character in its string parts, which is exactly the use case for the RFC 6570 {&var} (form-continuation) operator.
How it manifests
The {&var} operator exists specifically for templates where a literal ? already appears in the template string, and additional query parameters should be appended with &. For template /search?static=true{&q}, the parser produces two parts: the string /search?static=true and the expression {&q}. The string part (including its literal ?) gets escaped into the path regex as ^/search\?static\=true$. However, the URI /search?static=true&q=test is split at the first ?, yielding pathPart="/search" and queryPart="static=true&q=test". The regex ^/search\?static\=true$ is then matched against just "/search", which obviously fails, and the method returns null.
Step-by-step proof
- Template:
/search?static=true{&q} parse()produces:["/search?static=true", {operator: "&", names: ["q"]}]match("/search?static=true&q=test")is called- Line 260:
queryIndex = uri.indexOf("?")→ index 7 (the literal?after "search") pathPart = "/search",queryPart = "static=true&q=test"- Loop over parts: string
"/search?static=true"→escapeRegExp→"/search\\?static\\=true"added to path regex - Expression
{&q}has operator&→ pushed toqueryParts, not included in regex - Final regex:
^/search\?static\=true$ pathPart.match(regex)→"/search".match(/^\/search\?static\=true$/)→null- Method returns
null— match fails
The even simpler case X{&var} matching X&var=test also fails: the URI has no ? so pathPart="X&var=test" but the regex is ^X$.
Impact
This completely breaks {&var} template matching, which is part of the RFC 6570 specification. The {&var} operator is the canonical way to express "append additional query parameters to a URI that already has some." While {?var} (which introduces the ?) is more common in MCP, the {&var} operator is a valid and supported pattern that previously worked correctly.
How to fix
The split logic needs to account for literal ? characters in template string parts. One approach: count the number of literal ? characters in string parts, and split the URI at the Nth+1 ? (where N is the count of literal ? chars in string parts). Alternatively, detect whether the template contains any {&var} expressions and, if so, include the literal query string in the path regex rather than splitting at the first ?.
There was a problem hiding this comment.
Makes sense. I copied over your commit in 749fc4b and added an extra spec you introduced.
There was a problem hiding this comment.
I had to use .replace(/\*/g, '') instead of replaceAll('*', '') since v1.x targets es2020
felixweinberger
left a comment
There was a problem hiding this comment.
Requesting another turn on the automated review finding
ad9c488 to
749fc4b
Compare
…s edge cases backporting from modelcontextprotocol@7e25ee3
b66e6c6 to
98e31bb
Compare
…agment handling
- Remove the hasLiteralQuery special-case branch from match(). Four rounds
of review found bugs in it; the two-code-path design was inherently fragile.
- Reject any literal '?' in template string segments at construction time
with a clear error pointing at the {?param} alternative. No real-world
MCP resource templates use this pattern.
- Fix fragment handling: strip #fragment before splitting path/query, so
/path?a=1#frag correctly extracts {a:'1'} instead of {a:'1#frag'}.
- Replace 7 hasLiteralQuery tests with construction-validation tests and
fragment-stripping tests.
Net: -33 lines of match() logic, one code path, correct by construction.
ported from modelcontextprotocol@8a9aefb
|
@felixweinberger I'm continuing to backport your commits from #1785. Let me know if I can assist you in any other way. |
Note: This is the v1.x backport of #1396.
This PR addresses several limitations of the current
UriTemplate#matchimplementation:Omitted query parameters are parsed as an empty string per guidance of RFC 6570 Section 2.3.
Motivation and Context
Fixes #1079
Fixes #149
How Has This Been Tested?
New test additions cover all of the cases mentioned in #1079 and #149 as well as a few additional potential edge cases I could come up with.
Breaking Changes
n/a
Types of changes
Checklist
Additional context