Skip to content

Commit 2009901

Browse files
authored
improvement(api): add native support for form-urlencoded inputs into API block (#1033)
1 parent e46045d commit 2009901

File tree

5 files changed

+133
-9
lines changed

5 files changed

+133
-9
lines changed

apps/sim/tools/__test-utils__/test-tools.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,18 @@ export class ToolTester<P = any, R = any> {
192192
const response = await this.mockFetch(url, {
193193
method: method,
194194
headers: this.tool.request.headers(params),
195-
body: this.tool.request.body ? JSON.stringify(this.tool.request.body(params)) : undefined,
195+
body: this.tool.request.body
196+
? (() => {
197+
const bodyResult = this.tool.request.body(params)
198+
const headers = this.tool.request.headers(params)
199+
const isPreformattedContent =
200+
headers['Content-Type'] === 'application/x-ndjson' ||
201+
headers['Content-Type'] === 'application/x-www-form-urlencoded'
202+
return isPreformattedContent && typeof bodyResult === 'string'
203+
? bodyResult
204+
: JSON.stringify(bodyResult)
205+
})()
206+
: undefined,
196207
})
197208

198209
if (!response.ok) {

apps/sim/tools/http/request.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,26 @@ describe('HTTP Request Tool', () => {
109109
})
110110
})
111111

112+
it.concurrent('should respect custom Content-Type headers', () => {
113+
// Custom Content-Type should not be overridden
114+
const headers = tester.getRequestHeaders({
115+
url: 'https://api.example.com',
116+
method: 'POST',
117+
body: { key: 'value' },
118+
headers: [{ Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' }],
119+
})
120+
expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded')
121+
122+
// Case-insensitive Content-Type should not be overridden
123+
const headers2 = tester.getRequestHeaders({
124+
url: 'https://api.example.com',
125+
method: 'POST',
126+
body: { key: 'value' },
127+
headers: [{ Key: 'content-type', Value: 'text/plain' }],
128+
})
129+
expect(headers2['content-type']).toBe('text/plain')
130+
})
131+
112132
it('should set dynamic Referer header correctly', async () => {
113133
const originalWindow = global.window
114134
Object.defineProperty(global, 'window', {
@@ -164,6 +184,30 @@ describe('HTTP Request Tool', () => {
164184
})
165185
})
166186

187+
describe('Body Construction', () => {
188+
it.concurrent('should handle JSON bodies correctly', () => {
189+
const body = { username: 'test', password: 'secret' }
190+
191+
expect(
192+
tester.getRequestBody({
193+
url: 'https://api.example.com',
194+
body,
195+
})
196+
).toEqual(body)
197+
})
198+
199+
it.concurrent('should handle FormData correctly', () => {
200+
const formData = { file: 'test.txt', content: 'file content' }
201+
202+
const result = tester.getRequestBody({
203+
url: 'https://api.example.com',
204+
formData,
205+
})
206+
207+
expect(result).toBeInstanceOf(FormData)
208+
})
209+
})
210+
167211
describe('Request Execution', () => {
168212
it('should apply default and dynamic headers to requests', async () => {
169213
// Setup mock response
@@ -253,6 +297,59 @@ describe('HTTP Request Tool', () => {
253297
expect(bodyArg).toEqual(body)
254298
})
255299

300+
it('should handle POST requests with URL-encoded form data', async () => {
301+
// Setup mock response
302+
tester.setup({ result: 'success' })
303+
304+
// Create test body
305+
const body = { username: 'testuser123', password: 'testpass456', email: '[email protected]' }
306+
307+
// Execute the tool with form-urlencoded content type
308+
await tester.execute({
309+
url: 'https://api.example.com/oauth/token',
310+
method: 'POST',
311+
body,
312+
headers: [{ cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } }],
313+
})
314+
315+
// Verify the request was made with correct headers
316+
const fetchCall = (global.fetch as any).mock.calls[0]
317+
expect(fetchCall[0]).toBe('https://api.example.com/oauth/token')
318+
expect(fetchCall[1].method).toBe('POST')
319+
expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded')
320+
321+
// Verify the body is URL-encoded (should not be JSON stringified)
322+
expect(fetchCall[1].body).toBe(
323+
'username=testuser123&password=testpass456&email=test%40example.com'
324+
)
325+
})
326+
327+
it('should handle OAuth client credentials requests', async () => {
328+
// Setup mock response for OAuth token endpoint
329+
tester.setup({ access_token: 'token123', token_type: 'Bearer' })
330+
331+
// Execute OAuth client credentials request
332+
await tester.execute({
333+
url: 'https://oauth.example.com/token',
334+
method: 'POST',
335+
body: { grant_type: 'client_credentials', scope: 'read write' },
336+
headers: [
337+
{ cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } },
338+
{ cells: { Key: 'Authorization', Value: 'Basic Y2xpZW50OnNlY3JldA==' } },
339+
],
340+
})
341+
342+
// Verify the OAuth request was properly formatted
343+
const fetchCall = (global.fetch as any).mock.calls[0]
344+
expect(fetchCall[0]).toBe('https://oauth.example.com/token')
345+
expect(fetchCall[1].method).toBe('POST')
346+
expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded')
347+
expect(fetchCall[1].headers.Authorization).toBe('Basic Y2xpZW50OnNlY3JldA==')
348+
349+
// Verify the body is URL-encoded
350+
expect(fetchCall[1].body).toBe('grant_type=client_credentials&scope=read+write')
351+
})
352+
256353
it('should handle errors correctly', async () => {
257354
// Setup error response
258355
tester.setup(mockHttpResponses.error, { ok: false, status: 400 })

apps/sim/tools/http/request.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,12 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
6767
const processedUrl = processUrl(params.url, params.pathParams, params.params)
6868
const allHeaders = getDefaultHeaders(headers, processedUrl)
6969

70-
// Set appropriate Content-Type
70+
// Set appropriate Content-Type only if not already specified by user
7171
if (params.formData) {
7272
// Don't set Content-Type for FormData, browser will set it with boundary
7373
return allHeaders
7474
}
75-
if (params.body) {
75+
if (params.body && !allHeaders['Content-Type'] && !allHeaders['content-type']) {
7676
allHeaders['Content-Type'] = 'application/json'
7777
}
7878

@@ -89,6 +89,24 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
8989
}
9090

9191
if (params.body) {
92+
// Check if user wants URL-encoded form data
93+
const headers = transformTable(params.headers || null)
94+
const contentType = headers['Content-Type'] || headers['content-type']
95+
96+
if (
97+
contentType === 'application/x-www-form-urlencoded' &&
98+
typeof params.body === 'object'
99+
) {
100+
// Convert JSON object to URL-encoded string
101+
const urlencoded = new URLSearchParams()
102+
Object.entries(params.body).forEach(([key, value]) => {
103+
if (value !== undefined && value !== null) {
104+
urlencoded.append(key, String(value))
105+
}
106+
})
107+
return urlencoded.toString()
108+
}
109+
92110
return params.body
93111
}
94112

apps/sim/tools/utils.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ describe('formatRequestParams', () => {
164164
})
165165

166166
// Return a preformatted body
167-
mockTool.request.body = vi.fn().mockReturnValue({ body: 'key1=value1&key2=value2' })
167+
mockTool.request.body = vi.fn().mockReturnValue('key1=value1&key2=value2')
168168

169169
const params = { method: 'POST' }
170170
const result = formatRequestParams(mockTool, params)
@@ -179,9 +179,7 @@ describe('formatRequestParams', () => {
179179
})
180180

181181
// Return a preformatted body for NDJSON
182-
mockTool.request.body = vi.fn().mockReturnValue({
183-
body: '{"prompt": "Hello"}\n{"prompt": "World"}',
184-
})
182+
mockTool.request.body = vi.fn().mockReturnValue('{"prompt": "Hello"}\n{"prompt": "World"}')
185183

186184
const params = { method: 'POST' }
187185
const result = formatRequestParams(mockTool, params)

apps/sim/tools/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ export function formatRequestParams(tool: ToolConfig, params: Record<string, any
6363
headers['Content-Type'] === 'application/x-ndjson' ||
6464
headers['Content-Type'] === 'application/x-www-form-urlencoded'
6565
const body = hasBody
66-
? isPreformattedContent && bodyResult
67-
? bodyResult.body
66+
? isPreformattedContent && typeof bodyResult === 'string'
67+
? bodyResult
6868
: JSON.stringify(bodyResult)
6969
: undefined
7070

0 commit comments

Comments
 (0)