diff --git a/src/shared/uriTemplate.test.ts b/src/shared/uriTemplate.test.ts index 043f9325d..baea3920b 100644 --- a/src/shared/uriTemplate.test.ts +++ b/src/shared/uriTemplate.test.ts @@ -191,11 +191,60 @@ 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', page: '' }); + 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({ q: '', page: '' }); + expect(template.variableNames).toEqual(['q', 'page']); + }); + + 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', + p: '', + sort: '' + }); + 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']); + }); }); describe('security and edge cases', () => { diff --git a/src/shared/uriTemplate.ts b/src/shared/uriTemplate.ts index 1dd57f56f..3c7e2c5cc 100644 --- a/src/shared/uriTemplate.ts +++ b/src/shared/uriTemplate.ts @@ -247,38 +247,91 @@ export class UriTemplate { match(uri: string): Variables | null { UriTemplate.validateLength(uri, MAX_TEMPLATE_LENGTH, 'URI'); + + // 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); + + // Build regex pattern for path (non-query) parts let pattern = '^'; - const names: Array<{ name: string; exploded: boolean }> = []; + const names: Array<{ name: string; exploded: boolean; isQuery: boolean }> = []; + const queryParts: Array<{ name: string; exploded: boolean }> = []; for (const part of this.parts) { if (typeof part === 'string') { pattern += this.escapeRegExp(part); } else { - const patterns = this.partToRegExp(part); - for (const { pattern: partPattern, name } of patterns) { - pattern += partPattern; - names.push({ name, exploded: part.exploded }); + if (part.operator === '?' || part.operator === '&') { + // Collect query parameter names for later extraction + for (const name of part.names) { + queryParts.push({ name, exploded: part.exploded }); + } + } else { + // Handle non-query parts normally + const patterns = this.partToRegExp(part); + for (const { pattern: partPattern, name } of patterns) { + pattern += partPattern; + names.push({ name, exploded: part.exploded, isQuery: false }); + } } } } - pattern += '$'; + // Match the path part (without query parameters) + pattern += '(?:\\?.*)?$'; // Allow optional query string at the end 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 (let i = 0; i < names.length; i++) { - const { name, exploded } = names[i]; - const value = match[i + 1]; - const cleanName = name.replace('*', ''); - if (exploded && value.includes(',')) { - result[cleanName] = value.split(','); - } else { - result[cleanName] = value; + // Extract non-query parameters + let matchIndex = 0; + for (const { name, exploded, isQuery } of names) { + if (!isQuery) { + const value = match[matchIndex + 1]; + const cleanName = name.replace('*', ''); + + if (exploded && value && value.includes(',')) { + result[cleanName] = value.split(','); + } else { + result[cleanName] = value; + } + matchIndex++; + } + } + + // Extract query parameters from query string + if (queryParts.length > 0) { + const queryParams = new Map(); + if (queryPart) { + // Parse query string + const pairs = queryPart.split('&'); + for (const pair of pairs) { + const equalIndex = pair.indexOf('='); + if (equalIndex !== -1) { + const key = decodeURIComponent(pair.slice(0, equalIndex)); + const value = decodeURIComponent(pair.slice(equalIndex + 1)); + queryParams.set(key, value); + } + } + } + + // Extract values for each expected query parameter + for (const { name, exploded } of queryParts) { + const cleanName = name.replace('*', ''); + const value = queryParams.get(cleanName); + + if (value === undefined) { + result[cleanName] = ''; + } else if (exploded && value.includes(',')) { + result[cleanName] = value.split(','); + } else { + result[cleanName] = value; + } } }