Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 49 additions & 0 deletions src/shared/uriTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
83 changes: 68 additions & 15 deletions src/shared/uriTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
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;
}
}
}

Expand Down
Loading