|
|
|
@@ -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<HttpNodeType> = { |
|
|
|
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<Props> = ({ 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 ( |
|
|
|
<Modal |
|
|
|
title={t('workflow.nodes.http.curl.title')} |
|
|
|
isShow={isShow} |
|
|
|
onClose={onHide} |
|
|
|
className='!w-[400px] !max-w-[400px] !p-4' |
|
|
|
> |
|
|
|
<div> |
|
|
|
<textarea |
|
|
|
value={inputString} |
|
|
|
className='w-full my-3 p-3 text-sm text-gray-900 border-0 rounded-lg grow bg-gray-100 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-gray-200 h-40' |
|
|
|
onChange={e => setInputString(e.target.value)} |
|
|
|
placeholder={t('workflow.nodes.http.curl.placeholder')!} |
|
|
|
/> |
|
|
|
</div> |
|
|
|
<div className='mt-4 flex justify-end space-x-2'> |
|
|
|
<Button className='!w-[95px]' onClick={onHide} >{t('common.operation.cancel')}</Button> |
|
|
|
<Button className='!w-[95px]' variant='primary' onClick={handleSave} > {t('common.operation.save')}</Button> |
|
|
|
</div> |
|
|
|
</Modal> |
|
|
|
) |
|
|
|
} |
|
|
|
|
|
|
|
export default React.memo(CurlPanel) |