diff --git a/web/app/components/workflow/nodes/http/components/curl-panel.tsx b/web/app/components/workflow/nodes/http/components/curl-panel.tsx new file mode 100644 index 0000000000..9c5dddedb7 --- /dev/null +++ b/web/app/components/workflow/nodes/http/components/curl-panel.tsx @@ -0,0 +1,154 @@ +'use client' +import type { FC } from 'react' +import React, { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { BodyType, type HttpNodeType, Method } from '../types' +import Modal from '@/app/components/base/modal' +import Button from '@/app/components/base/button' +import Toast from '@/app/components/base/toast' +import { useNodesInteractions } from '@/app/components/workflow/hooks' + +type Props = { + nodeId: string + isShow: boolean + onHide: () => void + handleCurlImport: (node: HttpNodeType) => void +} + +const parseCurl = (curlCommand: string): { node: HttpNodeType | null; error: string | null } => { + if (!curlCommand.trim().toLowerCase().startsWith('curl')) + return { node: null, error: 'Invalid cURL command. Command must start with "curl".' } + + const node: Partial = { + title: 'HTTP Request', + desc: 'Imported from cURL', + method: Method.get, + url: '', + headers: '', + params: '', + body: { type: BodyType.none, data: '' }, + } + const args = curlCommand.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [] + + for (let i = 1; i < args.length; i++) { + const arg = args[i].replace(/^['"]|['"]$/g, '') + switch (arg) { + case '-X': + case '--request': + if (i + 1 >= args.length) + return { node: null, error: 'Missing HTTP method after -X or --request.' } + node.method = (args[++i].replace(/^['"]|['"]$/g, '') as Method) || Method.get + break + case '-H': + case '--header': + if (i + 1 >= args.length) + return { node: null, error: 'Missing header value after -H or --header.' } + node.headers += (node.headers ? '\n' : '') + args[++i].replace(/^['"]|['"]$/g, '') + break + case '-d': + case '--data': + case '--data-raw': + case '--data-binary': + if (i + 1 >= args.length) + return { node: null, error: 'Missing data value after -d, --data, --data-raw, or --data-binary.' } + node.body = { type: BodyType.rawText, data: args[++i].replace(/^['"]|['"]$/g, '') } + break + case '-F': + case '--form': { + if (i + 1 >= args.length) + return { node: null, error: 'Missing form data after -F or --form.' } + if (node.body?.type !== BodyType.formData) + node.body = { type: BodyType.formData, data: '' } + const formData = args[++i].replace(/^['"]|['"]$/g, '') + const [key, ...valueParts] = formData.split('=') + if (!key) + return { node: null, error: 'Invalid form data format.' } + let value = valueParts.join('=') + + // To support command like `curl -F "file=@/path/to/file;type=application/zip"` + // the `;type=application/zip` should translate to `Content-Type: application/zip` + const typeMatch = value.match(/^(.+?);type=(.+)$/) + if (typeMatch) { + const [, actualValue, mimeType] = typeMatch + value = actualValue + node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}` + } + + node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}` + break + } + case '--json': + if (i + 1 >= args.length) + return { node: null, error: 'Missing JSON data after --json.' } + node.body = { type: BodyType.json, data: args[++i].replace(/^['"]|['"]$/g, '') } + break + default: + if (arg.startsWith('http') && !node.url) + node.url = arg + break + } + } + + if (!node.url) + return { node: null, error: 'Missing URL or url not start with http.' } + + // Extract query params from URL + const urlParts = node.url?.split('?') || [] + if (urlParts.length > 1) { + node.url = urlParts[0] + node.params = urlParts[1].replace(/&/g, '\n').replace(/=/g, ': ') + } + + return { node: node as HttpNodeType, error: null } +} + +const CurlPanel: FC = ({ nodeId, isShow, onHide, handleCurlImport }) => { + const [inputString, setInputString] = useState('') + const { handleNodeSelect } = useNodesInteractions() + const { t } = useTranslation() + + const handleSave = useCallback(() => { + const { node, error } = parseCurl(inputString) + if (error) { + Toast.notify({ + type: 'error', + message: error, + }) + return + } + if (!node) + return + + onHide() + handleCurlImport(node) + // Close the panel then open it again to make the panel re-render + handleNodeSelect(nodeId, true) + setTimeout(() => { + handleNodeSelect(nodeId) + }, 0) + }, [onHide, nodeId, inputString, handleNodeSelect, handleCurlImport]) + + return ( + +
+