chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,263 @@
import '@sim/testing/mocks/executor'
import { inputValidationMock, inputValidationMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
const mockGetTool = vi.mocked(getTool)
const mockExecuteTool = executeTool as Mock
const mockValidateUrlWithDNS = inputValidationMockFns.mockValidateUrlWithDNS
describe('ApiBlockHandler', () => {
let handler: ApiBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockApiTool: ToolConfig
beforeEach(() => {
handler = new ApiBlockHandler()
mockBlock = {
id: 'api-block-1',
metadata: { id: BlockType.API, name: 'Test API Block' },
position: { x: 10, y: 10 },
config: { tool: 'http_request', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
}
mockApiTool = {
id: 'http_request',
name: 'HTTP Request Tool',
description: 'Makes an HTTP request',
version: '1.0',
params: {
url: { type: 'string', required: true },
method: { type: 'string', default: 'GET' },
headers: { type: 'object' },
body: { type: 'any' },
},
request: {
url: 'https://example.com/api',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => params,
},
}
// Reset mocks using vi
vi.clearAllMocks()
mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: '93.184.216.34',
originalHostname: 'example.com',
})
// Set up mockGetTool to return the mockApiTool
mockGetTool.mockImplementation((toolId) => {
if (toolId === 'http_request') {
return mockApiTool
}
return undefined
})
// Default mock implementations
mockExecuteTool.mockResolvedValue({ success: true, output: { data: 'Success' } })
})
it('should handle api blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonApiBlock: SerializedBlock = {
...mockBlock,
metadata: { id: 'other-block' },
}
expect(handler.canHandle(nonApiBlock)).toBe(false)
})
it('should execute api block correctly with valid inputs', async () => {
const inputs = {
url: 'https://example.com/api',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
}
const expectedOutput = { data: 'Success' }
mockExecuteTool.mockResolvedValue({ success: true, output: { data: 'Success' } })
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetTool).toHaveBeenCalledWith('http_request')
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
{
...inputs,
body: { key: 'value' }, // Expect parsed body
_context: { workflowId: 'test-workflow-id' },
},
{ executionContext: mockContext }
)
expect(result).toEqual(expectedOutput)
})
it('should handle missing URL gracefully (empty success response)', async () => {
const inputs = {
url: '', // Empty URL
method: 'GET',
}
const expectedOutput = { data: null, status: 200, headers: {} }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetTool).toHaveBeenCalledWith('http_request')
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(result).toEqual(expectedOutput)
})
it('should throw error for invalid URL format (no protocol)', async () => {
const inputs = { url: 'example.com/api' }
mockValidateUrlWithDNS.mockResolvedValueOnce({
isValid: false,
error: 'url must be a valid URL',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'url must be a valid URL'
)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('should throw error for generally invalid URL format', async () => {
const inputs = { url: 'htp:/invalid-url' }
mockValidateUrlWithDNS.mockResolvedValueOnce({
isValid: false,
error: 'url must use https:// protocol',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'url must use https:// protocol'
)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('should parse JSON string body correctly', async () => {
const inputs = {
url: 'https://example.com/api',
body: ' { "key": "value", "nested": { "num": 1 } } ', // With extra whitespace
}
const expectedParsedBody = { key: 'value', nested: { num: 1 } }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: expectedParsedBody }),
{ executionContext: mockContext }
)
})
it('should keep non-JSON string body as string', async () => {
const inputs = {
url: 'https://example.com/api',
body: 'This is plain text',
}
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: 'This is plain text' }),
{ executionContext: mockContext }
)
})
it('should handle null body by converting to undefined', async () => {
const inputs = {
url: 'https://example.com/api',
body: null,
}
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: undefined }),
{ executionContext: mockContext }
)
})
it('should handle API errors correctly and format message', async () => {
const inputs = {
url: 'https://example.com/notfound',
method: 'GET',
}
const errorOutput = { status: 404, statusText: 'Not Found' }
mockExecuteTool.mockResolvedValue({
success: false,
output: errorOutput,
error: 'Resource not found',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'HTTP Request failed: URL: https://example.com/notfound | Method: GET | Error: Resource not found | Status: 404 | Status text: Not Found - The requested resource was not found'
)
expect(mockExecuteTool).toHaveBeenCalled()
})
it('should throw error if tool is not found', async () => {
const inputs = { url: 'https://example.com' }
// Override mock to return undefined for this test
mockGetTool.mockImplementation(() => undefined)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Tool not found: http_request'
)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('should handle CORS error suggestion', async () => {
const inputs = { url: 'https://example.com/cors-issue' }
mockExecuteTool.mockResolvedValue({
success: false,
error: 'Request failed due to CORS policy',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/CORS policy prevented the request, try using a proxy or server-side request/
)
})
it('should handle generic fetch error suggestion', async () => {
const inputs = { url: 'https://unreachable.local' }
mockExecuteTool.mockResolvedValue({ success: false, error: 'Failed to fetch' })
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/Network error, check if the URL is accessible and if you have internet connectivity/
)
})
})
@@ -0,0 +1,166 @@
import { createLogger } from '@sim/logger'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { BlockType, HTTP } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool } from '@/tools/utils'
const logger = createLogger('ApiBlockHandler')
/**
* Handler for API blocks that make external HTTP requests.
*/
export class ApiBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.API
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
const tool = getTool(block.config.tool)
if (!tool) {
throw new Error(`Tool not found: ${block.config.tool}`)
}
if (tool.name?.includes('HTTP') && (!inputs.url || inputs.url.trim() === '')) {
return { data: null, status: HTTP.STATUS.OK, headers: {} }
}
if (tool.name?.includes('HTTP') && inputs.url) {
let urlToValidate = inputs.url
if (typeof urlToValidate === 'string') {
if (
(urlToValidate.startsWith('"') && urlToValidate.endsWith('"')) ||
(urlToValidate.startsWith("'") && urlToValidate.endsWith("'"))
) {
urlToValidate = urlToValidate.slice(1, -1)
inputs.url = urlToValidate
}
}
const urlValidation = await validateUrlWithDNS(urlToValidate, 'url')
if (!urlValidation.isValid) {
throw new Error(urlValidation.error)
}
}
try {
const processedInputs = { ...inputs }
if (processedInputs.body !== undefined) {
if (typeof processedInputs.body === 'string') {
try {
const trimmedBody = processedInputs.body.trim()
if (trimmedBody.startsWith('{') || trimmedBody.startsWith('[')) {
processedInputs.body = JSON.parse(trimmedBody)
}
} catch (e) {}
} else if (processedInputs.body === null) {
processedInputs.body = undefined
}
}
const result = await executeTool(
block.config.tool,
{
...processedInputs,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
callChain: ctx.callChain,
},
},
{ executionContext: ctx }
)
if (!result.success) {
const errorDetails = []
if (inputs.url) errorDetails.push(`URL: ${inputs.url}`)
if (inputs.method) errorDetails.push(`Method: ${inputs.method}`)
if (result.error) errorDetails.push(`Error: ${result.error}`)
if (result.output?.status) errorDetails.push(`Status: ${result.output.status}`)
if (result.output?.statusText) errorDetails.push(`Status text: ${result.output.statusText}`)
let suggestion = ''
if (result.output?.status === HTTP.STATUS.FORBIDDEN) {
suggestion = ' - This may be due to CORS restrictions or authorization issues'
} else if (result.output?.status === HTTP.STATUS.NOT_FOUND) {
suggestion = ' - The requested resource was not found'
} else if (result.output?.status === HTTP.STATUS.TOO_MANY_REQUESTS) {
suggestion = ' - Too many requests, you may need to implement rate limiting'
} else if (result.output?.status >= HTTP.STATUS.SERVER_ERROR) {
suggestion = ' - Server error, the target server is experiencing issues'
} else if (result.error?.includes('CORS')) {
suggestion =
' - CORS policy prevented the request, try using a proxy or server-side request'
} else if (result.error?.includes('Failed to fetch')) {
suggestion =
' - Network error, check if the URL is accessible and if you have internet connectivity'
}
const errorMessage =
errorDetails.length > 0
? `HTTP Request failed: ${errorDetails.join(' | ')}${suggestion}`
: `API request to ${tool.name || block.config.tool} failed with no error message`
const error = new Error(errorMessage)
Object.assign(error, {
toolId: block.config.tool,
toolName: tool.name || 'Unknown tool',
blockId: block.id,
blockName: block.metadata?.name || 'Unnamed Block',
output: result.output || {},
status: result.output?.status || null,
request: {
url: inputs.url,
method: inputs.method || 'GET',
},
timestamp: new Date().toISOString(),
})
throw error
}
return result.output
} catch (error: any) {
if (!error.message || error.message === 'undefined (undefined)') {
let errorMessage = `API request to ${tool.name || block.config.tool} failed`
if (inputs.url) errorMessage += `: ${inputs.url}`
if (error.status) errorMessage += ` (Status: ${error.status})`
if (error.statusText) errorMessage += ` - ${error.statusText}`
if (errorMessage === `API request to ${tool.name || block.config.tool} failed`) {
errorMessage += ` - ${block.metadata?.name || 'Unknown error'}`
}
error.message = errorMessage
}
if (typeof error === 'object' && error !== null) {
if (!error.toolId) error.toolId = block.config.tool
if (!error.blockName) error.blockName = block.metadata?.name || 'Unnamed Block'
if (inputs && !error.request) {
error.request = {
url: inputs.url,
method: inputs.method || 'GET',
}
}
}
throw error
}
}
}