diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index 2803bfeb846..5e1d3cea102 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -1,6 +1,11 @@ import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' import type { RequestParams, RequestResponse } from '@/tools/http/types' -import { getCredentialHeaderNames, getDefaultHeaders, processUrl } from '@/tools/http/utils' +import { + getCredentialHeaderNames, + getDefaultHeaders, + processUrl, + toHttpToolResponse, +} from '@/tools/http/utils' import { transformTable } from '@/tools/shared/table' import type { ToolConfig } from '@/tools/types' @@ -181,11 +186,6 @@ export const requestTool: ToolConfig = { transformResponse: async (response: Response) => { const contentType = response.headers.get('content-type') || '' - const headers: Record = {} - response.headers.forEach((value, key) => { - headers[key] = value - }) - const responseText = await readResponseTextWithLimit(response, { maxBytes: MAX_HTTP_RESPONSE_BODY_BYTES, label: 'HTTP Request response body', @@ -193,15 +193,7 @@ export const requestTool: ToolConfig = { }) const data = contentType.includes('application/json') ? JSON.parse(responseText) : responseText - return { - success: response.ok, - output: { - data, - status: response.status, - headers, - }, - error: undefined, // Errors are handled upstream in executeTool - } + return toHttpToolResponse(response, data) }, outputs: { diff --git a/apps/sim/tools/http/types.ts b/apps/sim/tools/http/types.ts index f5b727b816e..33d0f368b5e 100644 --- a/apps/sim/tools/http/types.ts +++ b/apps/sim/tools/http/types.ts @@ -21,8 +21,9 @@ export interface RequestParams { export interface RequestResponse extends ToolResponse { output: { data: unknown - status: number - headers: Record + /** The HTTP status code, or the body's own `status` for a legacy envelope. */ + status: unknown + headers: Record } } diff --git a/apps/sim/tools/http/utils.ts b/apps/sim/tools/http/utils.ts index 9cbf78ba74b..8618258aaaf 100644 --- a/apps/sim/tools/http/utils.ts +++ b/apps/sim/tools/http/utils.ts @@ -1,4 +1,6 @@ +import { isRecordLike, toRecord } from '@sim/utils/object' import { escapeRegExp } from '@sim/utils/string' +import type { RequestResponse } from '@/tools/http/types' import { transformTable } from '@/tools/shared/table' import type { TableRow } from '@/tools/types' @@ -153,3 +155,44 @@ export const processUrl = ( return canonicalizeFirstPartyApiUrl(url) } + +/** + * Builds the `http_request` / `webhook_request` result from a 2xx response and its parsed + * body; non-2xx responses are rejected upstream in `executeTool`. + * + * A JSON body shaped like the envelope the removed `/api/proxy` route returned + * (`{ success, status, data, headers? }`) is unwrapped exactly as these tools always have, + * so existing workflows keep reading the same outputs and taking the same error branch. + * A body with no `success` field (JSend's `{ status, data }`, for example) was never that + * envelope and is reported as the HTTP response it is. + */ +export function toHttpToolResponse(response: Response, body: unknown): RequestResponse { + if ( + isRecordLike(body) && + body.success !== undefined && + body.data !== undefined && + body.status !== undefined + ) { + const success = Boolean(body.success) + const { error } = body + return { + success, + output: { data: body.data, status: body.status, headers: toRecord(body.headers) }, + error: + success || error == null + ? undefined + : typeof error === 'string' + ? error + : JSON.stringify(error), + } + } + + return { + success: response.ok, + output: { + data: body, + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }, + } +} diff --git a/apps/sim/tools/http/webhook_request.ts b/apps/sim/tools/http/webhook_request.ts index 4e058211927..c23e8696872 100644 --- a/apps/sim/tools/http/webhook_request.ts +++ b/apps/sim/tools/http/webhook_request.ts @@ -1,6 +1,7 @@ import { hmacSha256Hex } from '@sim/security/hmac' import { generateId } from '@sim/utils/id' import type { RequestResponse, WebhookRequestParams } from '@/tools/http/types' +import { toHttpToolResponse } from '@/tools/http/utils' import type { ToolConfig } from '@/tools/types' /** @@ -76,24 +77,11 @@ export const webhookRequestTool: ToolConfig { const contentType = response.headers.get('content-type') || '' - const headers: Record = {} - response.headers.forEach((value, key) => { - headers[key] = value - }) - const data = await (contentType.includes('application/json') ? response.json() : response.text()) - return { - success: response.ok, - output: { - data, - status: response.status, - headers, - }, - error: undefined, - } + return toHttpToolResponse(response, data) }, outputs: { diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 4534fed46d0..8cbb632b402 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -5543,20 +5543,21 @@ describe('HTTP tools report the real HTTP outcome', () => { Reflect.deleteProperty(tools, 'webhook_request') }) - it.each([ + const httpTools = [ ['http_request', { url: 'https://api.example.com/orders', method: 'POST', body: {} }], ['webhook_request', { url: 'https://api.example.com/hooks', body: {} }], - ])( + ] as const + + function respondWith(body: unknown) { + mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + toSecureFetchResponse(Response.json(body, { headers: { 'x-request-id': 'req-123' } })) + ) + } + + it.each(httpTools)( '%s treats a 200 whose body has top-level status and data as a success', async (toolId, params) => { - mockSecureFetchWithPinnedIP.mockResolvedValueOnce( - toSecureFetchResponse( - Response.json( - { status: 'success', data: { orderId: 'A-1001' } }, - { headers: { 'x-request-id': 'req-123' } } - ) - ) - ) + respondWith({ status: 'success', data: { orderId: 'A-1001' } }) const result = await executeTool(toolId, params, { skipPostProcess: true }) @@ -5570,6 +5571,38 @@ describe('HTTP tools report the real HTTP outcome', () => { }) } ) + + it.each(httpTools)( + '%s keeps unwrapping a 200 body with success, status, and data', + async (toolId, params) => { + respondWith({ success: true, status: 'ok', data: { orderId: 'A-1001' } }) + + const result = await executeTool(toolId, params, { skipPostProcess: true }) + + expect(result, result.error).toMatchObject({ + success: true, + output: { status: 'ok', data: { orderId: 'A-1001' }, headers: {} }, + }) + } + ) + + it.each([ + ...httpTools.map( + ([toolId, params]) => [toolId, params, 'Out of stock', 'Out of stock'] as const + ), + ...httpTools.map( + ([toolId, params]) => [toolId, params, { code: 'OOS' }, '{"code":"OOS"}'] as const + ), + ])( + '%s keeps failing a 200 body that reports success: false', + async (toolId, params, error, expectedError) => { + respondWith({ success: false, status: 'error', data: null, error }) + + const result = await executeTool(toolId, params, { skipPostProcess: true }) + + expect(result).toMatchObject({ success: false, error: expectedError }) + } + ) }) describe('File Parameter Normalization', () => {