Skip to content

Commit 6c885f8

Browse files
authored
fix(http): substitute path parameters only by their whole name (#7914)
* fix(http): substitute path parameters only by their whole name The HTTP request tool replaced each path parameter with a plain string replace of `:${key}` over the whole URL. An empty key stripped the colon from the scheme, so `https://host` became `https//host` and the request was refused as not absolute; a numeric key rewrote the port into the host; and `:id` matched inside `:idx`. Models occasionally send an empty path-parameter entry, which made agent API tool calls fail intermittently. Substitute a key only when it starts like a JavaScript identifier, as path-to-regexp defines `:name` parameters, and end each placeholder where an identifier ends. * fix(http): prefer the longest path parameter key and accept Unicode identifiers
1 parent 59a364a commit 6c885f8

2 files changed

Lines changed: 70 additions & 3 deletions

File tree

‎apps/sim/tools/http/request.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,43 @@ describe('HTTP Request Tool', () => {
7070
expect(url.includes('chars')).toBe(true)
7171
})
7272

73+
it.concurrent('substitutes path parameters only by their whole name', () => {
74+
expect(processUrl('https://www.google.com', { '': '' })).toBe('https://www.google.com')
75+
expect(processUrl('https://api.example.com:8443/users/:id', { '8443': 'x', id: '42' })).toBe(
76+
'https://api.example.com:8443/users/42'
77+
)
78+
expect(processUrl('https://api.example.com/users/:idx/:id', { id: '1', idx: '2' })).toBe(
79+
'https://api.example.com/users/2/1'
80+
)
81+
expect(processUrl('https://api.example.com/users/:id-profile', { id: '7' })).toBe(
82+
'https://api.example.com/users/7-profile'
83+
)
84+
expect(processUrl('https://api.example.com/users/:user-id', { 'user-id': '9' })).toBe(
85+
'https://api.example.com/users/9'
86+
)
87+
expect(processUrl('https://api.example.com/users/:id', { id: '$&' })).toBe(
88+
'https://api.example.com/users/%24%26'
89+
)
90+
expect(processUrl('https://api.example.com/users/:user.name', { 'user.name': 'ada' })).toBe(
91+
'https://api.example.com/users/ada'
92+
)
93+
expect(processUrl('https://api.example.com/v1/:$ref', { $ref: 'x' })).toBe(
94+
'https://api.example.com/v1/x'
95+
)
96+
expect(processUrl('https://api.example.com/users/:id', { '/': 'x', '1': 'y' })).toBe(
97+
'https://api.example.com/users/:id'
98+
)
99+
expect(
100+
processUrl('https://api.example.com/:user-id/:user', { user: 'alice', 'user-id': '42' })
101+
).toBe('https://api.example.com/42/alice')
102+
expect(
103+
processUrl('https://api.example.com/:user.name', { user: 'alice', 'user.name': 'ada' })
104+
).toBe('https://api.example.com/ada')
105+
expect(processUrl('https://api.example.com/:é/:éa', { é: '42', éa: '7' })).toBe(
106+
'https://api.example.com/42/7'
107+
)
108+
})
109+
73110
it.concurrent('canonicalizes first-party API calls before the apex redirect', () => {
74111
expect(
75112
processUrl('https://sim.ai/api/v2/workflows', undefined, [

‎apps/sim/tools/http/utils.ts‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { escapeRegExp } from '@/executor/constants'
12
import { transformTable } from '@/tools/shared/table'
23
import type { TableRow } from '@/tools/types'
34

@@ -81,6 +82,37 @@ export const getDefaultHeaders = (
8182
return headers
8283
}
8384

85+
/**
86+
* A path parameter key must start like a JavaScript identifier, using the same character classes
87+
* path-to-regexp uses for `:name` parameters. That excludes an empty key and one starting with a
88+
* digit or `/`, the shapes that matched the scheme separator or a port; the rest of the key is left
89+
* as callers use it.
90+
*/
91+
const PATH_PARAM_KEY = /^[$_\p{ID_Start}][^\s/?#]*$/u
92+
const PATH_PARAM_NAME_CONTINUE = '[$\\u200c\\u200d\\p{ID_Continue}]'
93+
94+
/**
95+
* Replaces the first `:key` placeholder for each path parameter with its URL-encoded value.
96+
*
97+
* A placeholder ends where an identifier would, so `:id` never matches inside `:idx`, and longer
98+
* keys are substituted first, so `:user-id` is not consumed by a `user` key. A plain string replace
99+
* let an empty key strip the scheme's colon (`https://` became `https//`), a numeric key rewrite a
100+
* port, and `:id` match inside `:idx`.
101+
*/
102+
function substitutePathParams(url: string, pathParams: Record<string, string>): string {
103+
const entries = Object.entries(pathParams)
104+
.filter(([key]) => PATH_PARAM_KEY.test(key))
105+
.sort(([a], [b]) => b.length - a.length)
106+
let substituted = url
107+
for (const [key, value] of entries) {
108+
substituted = substituted.replace(
109+
new RegExp(`:${escapeRegExp(key)}(?!${PATH_PARAM_NAME_CONTINUE})`, 'u'),
110+
() => encodeURIComponent(value)
111+
)
112+
}
113+
return substituted
114+
}
115+
84116
/**
85117
* Processes a URL with path parameters and query parameters
86118
* @param url Base URL to process
@@ -98,9 +130,7 @@ export const processUrl = (
98130
}
99131

100132
if (pathParams) {
101-
Object.entries(pathParams).forEach(([key, value]) => {
102-
url = url.replace(`:${key}`, encodeURIComponent(value))
103-
})
133+
url = substitutePathParams(url, pathParams)
104134
}
105135

106136
if (queryParams) {

0 commit comments

Comments
 (0)