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
22 changes: 7 additions & 15 deletions apps/sim/tools/http/request.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -181,27 +186,14 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
transformResponse: async (response: Response) => {
const contentType = response.headers.get('content-type') || ''

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

const responseText = await readResponseTextWithLimit(response, {
maxBytes: MAX_HTTP_RESPONSE_BODY_BYTES,
label: 'HTTP Request response body',
allowNoBodyFallback: true,
})
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: {
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/tools/http/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ export interface RequestParams {
export interface RequestResponse extends ToolResponse {
output: {
data: unknown
status: number
headers: Record<string, string>
/** The HTTP status code, or the body's own `status` for a legacy envelope. */
status: unknown
headers: Record<string, unknown>
}
}

Expand Down
43 changes: 43 additions & 0 deletions apps/sim/tools/http/utils.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 &&
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
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()),
},
}
}
16 changes: 2 additions & 14 deletions apps/sim/tools/http/webhook_request.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -76,24 +77,11 @@ export const webhookRequestTool: ToolConfig<WebhookRequestParams, RequestRespons
transformResponse: async (response: Response) => {
const contentType = response.headers.get('content-type') || ''

const headers: Record<string, string> = {}
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: {
Expand Down
53 changes: 43 additions & 10 deletions apps/sim/tools/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand All @@ -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', () => {
Expand Down
Loading