Skip to content
Merged
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
37 changes: 37 additions & 0 deletions apps/sim/tools/http/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,43 @@ describe('HTTP Request Tool', () => {
expect(url.includes('chars')).toBe(true)
})

it.concurrent('substitutes path parameters only by their whole name', () => {
expect(processUrl('https://www.google.com', { '': '' })).toBe('https://www.google.com')
expect(processUrl('https://api.example.com:8443/users/:id', { '8443': 'x', id: '42' })).toBe(
'https://api.example.com:8443/users/42'
)
expect(processUrl('https://api.example.com/users/:idx/:id', { id: '1', idx: '2' })).toBe(
'https://api.example.com/users/2/1'
)
expect(processUrl('https://api.example.com/users/:id-profile', { id: '7' })).toBe(
'https://api.example.com/users/7-profile'
)
expect(processUrl('https://api.example.com/users/:user-id', { 'user-id': '9' })).toBe(
'https://api.example.com/users/9'
)
expect(processUrl('https://api.example.com/users/:id', { id: '$&' })).toBe(
'https://api.example.com/users/%24%26'
)
expect(processUrl('https://api.example.com/users/:user.name', { 'user.name': 'ada' })).toBe(
'https://api.example.com/users/ada'
)
expect(processUrl('https://api.example.com/v1/:$ref', { $ref: 'x' })).toBe(
'https://api.example.com/v1/x'
)
expect(processUrl('https://api.example.com/users/:id', { '/': 'x', '1': 'y' })).toBe(
'https://api.example.com/users/:id'
)
expect(
processUrl('https://api.example.com/:user-id/:user', { user: 'alice', 'user-id': '42' })
).toBe('https://api.example.com/42/alice')
expect(
processUrl('https://api.example.com/:user.name', { user: 'alice', 'user.name': 'ada' })
).toBe('https://api.example.com/ada')
expect(processUrl('https://api.example.com/:é/:éa', { é: '42', éa: '7' })).toBe(
'https://api.example.com/42/7'
)
})

it.concurrent('canonicalizes first-party API calls before the apex redirect', () => {
expect(
processUrl('https://sim.ai/api/v2/workflows', undefined, [
Expand Down
36 changes: 33 additions & 3 deletions apps/sim/tools/http/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { escapeRegExp } from '@/executor/constants'
import { transformTable } from '@/tools/shared/table'
import type { TableRow } from '@/tools/types'

Expand Down Expand Up @@ -81,6 +82,37 @@ export const getDefaultHeaders = (
return headers
}

/**
* A path parameter key must start like a JavaScript identifier, using the same character classes
* path-to-regexp uses for `:name` parameters. That excludes an empty key and one starting with a
* digit or `/`, the shapes that matched the scheme separator or a port; the rest of the key is left
* as callers use it.
*/
const PATH_PARAM_KEY = /^[$_\p{ID_Start}][^\s/?#]*$/u
const PATH_PARAM_NAME_CONTINUE = '[$\\u200c\\u200d\\p{ID_Continue}]'

/**
* Replaces the first `:key` placeholder for each path parameter with its URL-encoded value.
*
* A placeholder ends where an identifier would, so `:id` never matches inside `:idx`, and longer
* keys are substituted first, so `:user-id` is not consumed by a `user` key. A plain string replace
* let an empty key strip the scheme's colon (`https://` became `https//`), a numeric key rewrite a
* port, and `:id` match inside `:idx`.
*/
function substitutePathParams(url: string, pathParams: Record<string, string>): string {
const entries = Object.entries(pathParams)
.filter(([key]) => PATH_PARAM_KEY.test(key))
.sort(([a], [b]) => b.length - a.length)
let substituted = url
for (const [key, value] of entries) {
substituted = substituted.replace(
new RegExp(`:${escapeRegExp(key)}(?!${PATH_PARAM_NAME_CONTINUE})`, 'u'),
() => encodeURIComponent(value)
)
}
return substituted
}

/**
* Processes a URL with path parameters and query parameters
* @param url Base URL to process
Expand All @@ -98,9 +130,7 @@ export const processUrl = (
}

if (pathParams) {
Object.entries(pathParams).forEach(([key, value]) => {
url = url.replace(`:${key}`, encodeURIComponent(value))
})
url = substitutePathParams(url, pathParams)
}

if (queryParams) {
Expand Down
Loading