improvement(api): add native support for form-urlencoded inputs into API block (#1033)

This commit is contained in:
Waleed Latif
2025-08-19 12:32:15 -07:00
committed by Siddharth Ganesan
parent 69773c3174
commit d58ceb4bce
5 changed files with 133 additions and 9 deletions

View File

@@ -192,7 +192,18 @@ export class ToolTester<P = any, R = any> {
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) {

View File

@@ -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 })

View File

@@ -67,12 +67,12 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
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<RequestParams, RequestResponse> = {
}
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
}

View File

@@ -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)

View File

@@ -63,8 +63,8 @@ export function formatRequestParams(tool: ToolConfig, params: Record<string, any
headers['Content-Type'] === 'application/x-ndjson' ||
headers['Content-Type'] === 'application/x-www-form-urlencoded'
const body = hasBody
? isPreformattedContent && bodyResult
? bodyResult.body
? isPreformattedContent && typeof bodyResult === 'string'
? bodyResult
: JSON.stringify(bodyResult)
: undefined