From d58ceb4bce0743b82eec9496fc2bb4016afd6941 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 19 Aug 2025 12:32:15 -0700 Subject: [PATCH] improvement(api): add native support for form-urlencoded inputs into API block (#1033) --- apps/sim/tools/__test-utils__/test-tools.ts | 13 ++- apps/sim/tools/http/request.test.ts | 97 +++++++++++++++++++++ apps/sim/tools/http/request.ts | 22 ++++- apps/sim/tools/utils.test.ts | 6 +- apps/sim/tools/utils.ts | 4 +- 5 files changed, 133 insertions(+), 9 deletions(-) diff --git a/apps/sim/tools/__test-utils__/test-tools.ts b/apps/sim/tools/__test-utils__/test-tools.ts index 0f428e617e..094b8553f3 100644 --- a/apps/sim/tools/__test-utils__/test-tools.ts +++ b/apps/sim/tools/__test-utils__/test-tools.ts @@ -192,7 +192,18 @@ export class ToolTester

{ const response = await this.mockFetch(url, { method: method, headers: this.tool.request.headers(params), - body: this.tool.request.body ? JSON.stringify(this.tool.request.body(params)) : undefined, + body: this.tool.request.body + ? (() => { + const bodyResult = this.tool.request.body(params) + const headers = this.tool.request.headers(params) + const isPreformattedContent = + headers['Content-Type'] === 'application/x-ndjson' || + headers['Content-Type'] === 'application/x-www-form-urlencoded' + return isPreformattedContent && typeof bodyResult === 'string' + ? bodyResult + : JSON.stringify(bodyResult) + })() + : undefined, }) if (!response.ok) { diff --git a/apps/sim/tools/http/request.test.ts b/apps/sim/tools/http/request.test.ts index 09ad477da6..c49472f9d6 100644 --- a/apps/sim/tools/http/request.test.ts +++ b/apps/sim/tools/http/request.test.ts @@ -109,6 +109,26 @@ describe('HTTP Request Tool', () => { }) }) + it.concurrent('should respect custom Content-Type headers', () => { + // Custom Content-Type should not be overridden + const headers = tester.getRequestHeaders({ + url: 'https://api.example.com', + method: 'POST', + body: { key: 'value' }, + headers: [{ Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' }], + }) + expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded') + + // Case-insensitive Content-Type should not be overridden + const headers2 = tester.getRequestHeaders({ + url: 'https://api.example.com', + method: 'POST', + body: { key: 'value' }, + headers: [{ Key: 'content-type', Value: 'text/plain' }], + }) + expect(headers2['content-type']).toBe('text/plain') + }) + it('should set dynamic Referer header correctly', async () => { const originalWindow = global.window Object.defineProperty(global, 'window', { @@ -164,6 +184,30 @@ describe('HTTP Request Tool', () => { }) }) + describe('Body Construction', () => { + it.concurrent('should handle JSON bodies correctly', () => { + const body = { username: 'test', password: 'secret' } + + expect( + tester.getRequestBody({ + url: 'https://api.example.com', + body, + }) + ).toEqual(body) + }) + + it.concurrent('should handle FormData correctly', () => { + const formData = { file: 'test.txt', content: 'file content' } + + const result = tester.getRequestBody({ + url: 'https://api.example.com', + formData, + }) + + expect(result).toBeInstanceOf(FormData) + }) + }) + describe('Request Execution', () => { it('should apply default and dynamic headers to requests', async () => { // Setup mock response @@ -253,6 +297,59 @@ describe('HTTP Request Tool', () => { expect(bodyArg).toEqual(body) }) + it('should handle POST requests with URL-encoded form data', async () => { + // Setup mock response + tester.setup({ result: 'success' }) + + // Create test body + const body = { username: 'testuser123', password: 'testpass456', email: 'test@example.com' } + + // Execute the tool with form-urlencoded content type + await tester.execute({ + url: 'https://api.example.com/oauth/token', + method: 'POST', + body, + headers: [{ cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } }], + }) + + // Verify the request was made with correct headers + const fetchCall = (global.fetch as any).mock.calls[0] + expect(fetchCall[0]).toBe('https://api.example.com/oauth/token') + expect(fetchCall[1].method).toBe('POST') + expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded') + + // Verify the body is URL-encoded (should not be JSON stringified) + expect(fetchCall[1].body).toBe( + 'username=testuser123&password=testpass456&email=test%40example.com' + ) + }) + + it('should handle OAuth client credentials requests', async () => { + // Setup mock response for OAuth token endpoint + tester.setup({ access_token: 'token123', token_type: 'Bearer' }) + + // Execute OAuth client credentials request + await tester.execute({ + url: 'https://oauth.example.com/token', + method: 'POST', + body: { grant_type: 'client_credentials', scope: 'read write' }, + headers: [ + { cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } }, + { cells: { Key: 'Authorization', Value: 'Basic Y2xpZW50OnNlY3JldA==' } }, + ], + }) + + // Verify the OAuth request was properly formatted + const fetchCall = (global.fetch as any).mock.calls[0] + expect(fetchCall[0]).toBe('https://oauth.example.com/token') + expect(fetchCall[1].method).toBe('POST') + expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded') + expect(fetchCall[1].headers.Authorization).toBe('Basic Y2xpZW50OnNlY3JldA==') + + // Verify the body is URL-encoded + expect(fetchCall[1].body).toBe('grant_type=client_credentials&scope=read+write') + }) + it('should handle errors correctly', async () => { // Setup error response tester.setup(mockHttpResponses.error, { ok: false, status: 400 }) diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index 8d4b0b4271..add50fe7a9 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -67,12 +67,12 @@ export const requestTool: ToolConfig = { const processedUrl = processUrl(params.url, params.pathParams, params.params) const allHeaders = getDefaultHeaders(headers, processedUrl) - // Set appropriate Content-Type + // Set appropriate Content-Type only if not already specified by user if (params.formData) { // Don't set Content-Type for FormData, browser will set it with boundary return allHeaders } - if (params.body) { + if (params.body && !allHeaders['Content-Type'] && !allHeaders['content-type']) { allHeaders['Content-Type'] = 'application/json' } @@ -89,6 +89,24 @@ export const requestTool: ToolConfig = { } if (params.body) { + // Check if user wants URL-encoded form data + const headers = transformTable(params.headers || null) + const contentType = headers['Content-Type'] || headers['content-type'] + + if ( + contentType === 'application/x-www-form-urlencoded' && + typeof params.body === 'object' + ) { + // Convert JSON object to URL-encoded string + const urlencoded = new URLSearchParams() + Object.entries(params.body).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + urlencoded.append(key, String(value)) + } + }) + return urlencoded.toString() + } + return params.body } diff --git a/apps/sim/tools/utils.test.ts b/apps/sim/tools/utils.test.ts index bf6847831e..9b11fadd9f 100644 --- a/apps/sim/tools/utils.test.ts +++ b/apps/sim/tools/utils.test.ts @@ -164,7 +164,7 @@ describe('formatRequestParams', () => { }) // Return a preformatted body - mockTool.request.body = vi.fn().mockReturnValue({ body: 'key1=value1&key2=value2' }) + mockTool.request.body = vi.fn().mockReturnValue('key1=value1&key2=value2') const params = { method: 'POST' } const result = formatRequestParams(mockTool, params) @@ -179,9 +179,7 @@ describe('formatRequestParams', () => { }) // Return a preformatted body for NDJSON - mockTool.request.body = vi.fn().mockReturnValue({ - body: '{"prompt": "Hello"}\n{"prompt": "World"}', - }) + mockTool.request.body = vi.fn().mockReturnValue('{"prompt": "Hello"}\n{"prompt": "World"}') const params = { method: 'POST' } const result = formatRequestParams(mockTool, params) diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 1a0abfbc9c..5aa7fdc7d9 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -63,8 +63,8 @@ export function formatRequestParams(tool: ToolConfig, params: Record