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
7 changes: 4 additions & 3 deletions apps/sim/app/api/proxy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,15 @@ export async function POST(request: Request) {

try {
// Parse request body
const requestText = await request.text()
let requestBody
try {
requestBody = await request.json()
requestBody = JSON.parse(requestText)
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse request body`, {
logger.error(`[${requestId}] Failed to parse request body: ${requestText}`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
})
throw new Error('Invalid JSON in request body')
throw new Error(`Invalid JSON in request body: ${requestText}`)
}

const { toolId, params, executionContext } = requestBody
Expand Down
41 changes: 28 additions & 13 deletions apps/sim/tools/__test-utils__/test-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,37 @@ export function createMockFetch(
) {
const { ok = true, status = 200, headers = { 'Content-Type': 'application/json' } } = options

const mockFn = vi.fn().mockResolvedValue({
ok,
status,
headers: {
get: (key: string) => headers[key.toLowerCase()],
forEach: (callback: (value: string, key: string) => void) => {
Object.entries(headers).forEach(([key, value]) => callback(value, key))
},
},
json: vi.fn().mockResolvedValue(responseData),
text: vi
// Normalize header keys to lowercase for case-insensitive access
const normalizedHeaders: Record<string, string> = {}
Object.entries(headers).forEach(([key, value]) => (normalizedHeaders[key.toLowerCase()] = value))

const makeResponse = () => {
const jsonMock = vi.fn().mockResolvedValue(responseData)
const textMock = vi
.fn()
.mockResolvedValue(
typeof responseData === 'string' ? responseData : JSON.stringify(responseData)
),
})
)

const res: any = {
ok,
status,
headers: {
get: (key: string) => normalizedHeaders[key.toLowerCase()],
forEach: (callback: (value: string, key: string) => void) => {
Object.entries(normalizedHeaders).forEach(([key, value]) => callback(value, key))
},
},
json: jsonMock,
text: textMock,
}

// Implement clone() so production code that clones responses keeps working in tests
res.clone = vi.fn().mockImplementation(() => makeResponse())
return res
}

const mockFn = vi.fn().mockResolvedValue(makeResponse())

// Add preconnect property to satisfy TypeScript

Expand Down
79 changes: 37 additions & 42 deletions apps/sim/tools/http/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,29 +56,12 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
// Process the URL first to handle path/query params
const processedUrl = processUrl(params.url, params.pathParams, params.params)

// For external URLs that need proxying
// For external URLs that need proxying in the browser, we still return the
// external URL here and let executeTool route through the POST /api/proxy
// endpoint uniformly. This avoids querystring body encoding and prevents
// the proxy GET route from being hit from the client.
if (shouldUseProxy(processedUrl)) {
let proxyUrl = `/api/proxy?url=${encodeURIComponent(processedUrl)}`

if (params.method) {
proxyUrl += `&method=${encodeURIComponent(params.method)}`
}

if (params.body && ['POST', 'PUT', 'PATCH'].includes(params.method?.toUpperCase() || '')) {
const bodyStr =
typeof params.body === 'string' ? params.body : JSON.stringify(params.body)
proxyUrl += `&body=${encodeURIComponent(bodyStr)}`
}

// Forward all headers as URL parameters
const userHeaders = transformTable(params.headers || null)
for (const [key, value] of Object.entries(userHeaders)) {
if (value !== undefined && value !== null) {
proxyUrl += `&header.${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`
}
}

return proxyUrl
return processedUrl
}

return processedUrl
Expand Down Expand Up @@ -137,13 +120,26 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
},

transformResponse: async (response: Response) => {
// For proxy responses, we need to parse the JSON and extract the data
// Build headers once for consistent return structures
const headers: Record<string, string> = {}
response.headers.forEach((value, key) => {
headers[key] = value
})

const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
const jsonResponse = await response.json()
const isJson = contentType.includes('application/json')

if (isJson) {
// Use a clone to safely inspect JSON without consuming the original body
let jsonResponse: any
try {
jsonResponse = await response.clone().json()
} catch (_e) {
jsonResponse = undefined
}

// Check if this is a proxy response
if (jsonResponse.data !== undefined && jsonResponse.status !== undefined) {
// Proxy responses wrap the real payload
if (jsonResponse && jsonResponse.data !== undefined && jsonResponse.status !== undefined) {
return {
success: jsonResponse.success,
output: {
Expand All @@ -153,31 +149,30 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
},
error: jsonResponse.success
? undefined
: // Extract and display the actual API error message from the response if available
jsonResponse.data && typeof jsonResponse.data === 'object' && jsonResponse.data.error
: jsonResponse.data && typeof jsonResponse.data === 'object' && jsonResponse.data.error
? `HTTP error ${jsonResponse.status}: ${jsonResponse.data.error.message || JSON.stringify(jsonResponse.data.error)}`
: jsonResponse.error || `HTTP error ${jsonResponse.status}`,
}
}
}

// Standard response handling
const headers: Record<string, string> = {}
response.headers.forEach((value, key) => {
headers[key] = value
})

let data
try {
data = await (contentType.includes('application/json') ? response.json() : response.text())
} catch (error) {
data = await response.text()
// Non-proxy JSON response: return parsed JSON directly
return {
success: response.ok,
output: {
data: jsonResponse ?? (await response.text()),
status: response.status,
headers,
},
error: response.ok ? undefined : `HTTP error ${response.status}: ${response.statusText}`,
}
}

// Non-JSON response: return text
const textData = await response.text()
return {
success: response.ok,
output: {
data,
data: textData,
status: response.status,
headers,
},
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ export async function executeTool(
}
}

// For external APIs, use the proxy
// For external APIs, always use the proxy POST, and ensure the tool request
// builds a direct external URL (not the querystring proxy variant)
const result = await handleProxyRequest(toolId, contextParams, executionContext)

// Apply post-processing if available and not skipped
Expand Down
8 changes: 5 additions & 3 deletions apps/sim/tools/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,17 @@ export function formatRequestParams(tool: ToolConfig, params: Record<string, any
// Process URL
const url = typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url

// Process method
const method = params.method || tool.request.method || 'GET'
// Process method (support function or string on tool.request.method)
const methodFromTool =
typeof tool.request.method === 'function' ? tool.request.method(params) : tool.request.method
const method = (params.method || methodFromTool || 'GET').toUpperCase()

// Process headers
const headers = tool.request.headers ? tool.request.headers(params) : {}

// Process body
const hasBody = method !== 'GET' && method !== 'HEAD' && !!tool.request.body
const bodyResult = tool.request.body ? tool.request.body(params) : undefined
const hasBody = method !== 'GET' && method !== 'HEAD' && bodyResult !== undefined

// Special handling for NDJSON content type or 'application/x-www-form-urlencoded'
const isPreformattedContent =
Expand Down