diff --git a/apps/sim/app/api/proxy/route.ts b/apps/sim/app/api/proxy/route.ts index d2f22688ac..0bcc6660ba 100644 --- a/apps/sim/app/api/proxy/route.ts +++ b/apps/sim/app/api/proxy/route.ts @@ -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 diff --git a/apps/sim/tools/__test-utils__/test-tools.ts b/apps/sim/tools/__test-utils__/test-tools.ts index 0f428e617e..36e92a5a55 100644 --- a/apps/sim/tools/__test-utils__/test-tools.ts +++ b/apps/sim/tools/__test-utils__/test-tools.ts @@ -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 = {} + 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 diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index 50971005ae..e322564720 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -56,29 +56,12 @@ export const requestTool: ToolConfig = { // 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 @@ -137,13 +120,26 @@ export const requestTool: ToolConfig = { }, transformResponse: async (response: Response) => { - // For proxy responses, we need to parse the JSON and extract the data - const contentType = response.headers.get('content-type') || '' - if (contentType.includes('application/json')) { - const jsonResponse = await response.json() + // Build headers once for consistent return structures + const headers: Record = {} + response.headers.forEach((value, key) => { + headers[key] = value + }) - // Check if this is a proxy response - if (jsonResponse.data !== undefined && jsonResponse.status !== undefined) { + const contentType = response.headers.get('content-type') || '' + 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 + } + + // Proxy responses wrap the real payload + if (jsonResponse && jsonResponse.data !== undefined && jsonResponse.status !== undefined) { return { success: jsonResponse.success, output: { @@ -153,32 +149,30 @@ export const requestTool: ToolConfig = { }, 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}`, } } + + // 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}`, + } } - // Standard response handling - const headers: Record = {} - response.headers.forEach((value, key) => { - headers[key] = value - }) - - let data - try { - data = await (contentType.includes('application/json') ? response.json() : response.text()) - } catch (error) { - // If response body reading fails, we can't retry reading - just use error message - data = `Failed to parse response: ${error instanceof Error ? error.message : String(error)}` - } - + // Non-JSON response: return text + const textData = await response.text() return { success: response.ok, output: { - data, + data: textData, status: response.status, headers, }, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index d9b8ca9a0a..4de08bad23 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -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 @@ -399,9 +400,8 @@ async function handleInternalRequest( const response = await fetch(fullUrl, requestOptions) - // Clone the response immediately before any body consumption + // Clone the response for error checking while preserving original for transformResponse const responseForErrorCheck = response.clone() - const responseForTransform = response.clone() // Parse response data for error checking let responseData @@ -469,7 +469,7 @@ async function handleInternalRequest( // Success case: use transformResponse if available if (tool.transformResponse) { try { - const data = await tool.transformResponse(responseForTransform, params) + const data = await tool.transformResponse(response, params) return data } catch (transformError) { logger.error(`[${requestId}] Transform response error for ${toolId}:`, { diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 92a256f5ed..78b087c82a 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -45,15 +45,17 @@ export function formatRequestParams(tool: ToolConfig, params: Record