diff --git a/apps/sim/tools/http/request.test.ts b/apps/sim/tools/http/request.test.ts index 661239c7cbd..df59cabbd85 100644 --- a/apps/sim/tools/http/request.test.ts +++ b/apps/sim/tools/http/request.test.ts @@ -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, [ diff --git a/apps/sim/tools/http/utils.ts b/apps/sim/tools/http/utils.ts index 22cba3b2647..0495fb3073c 100644 --- a/apps/sim/tools/http/utils.ts +++ b/apps/sim/tools/http/utils.ts @@ -1,3 +1,4 @@ +import { escapeRegExp } from '@/executor/constants' import { transformTable } from '@/tools/shared/table' import type { TableRow } from '@/tools/types' @@ -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 { + 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 @@ -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) {