Selaa lähdekoodia

feat: add cURL import for http request node (#8656)

非法操作 6 kuukautta sitten
vanhempi
commit
8b16f07eb0

+ 154 - 0
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<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)

+ 34 - 9
web/app/components/workflow/nodes/http/panel.tsx

@@ -8,11 +8,13 @@ import EditBody from './components/edit-body'
 import AuthorizationModal from './components/authorization'
 import type { HttpNodeType } from './types'
 import Timeout from './components/timeout'
+import CurlPanel from './components/curl-panel'
 import cn from '@/utils/classnames'
 import Field from '@/app/components/workflow/nodes/_base/components/field'
 import Split from '@/app/components/workflow/nodes/_base/components/split'
 import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
 import { Settings01 } from '@/app/components/base/icons/src/vender/line/general'
+import { FileArrow01 } from '@/app/components/base/icons/src/vender/line/files'
 import type { NodePanelProps } from '@/app/components/workflow/types'
 import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/before-run-form'
 import ResultPanel from '@/app/components/workflow/run/result-panel'
@@ -53,6 +55,10 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
     inputVarValues,
     setInputVarValues,
     runResult,
+    isShowCurlPanel,
+    showCurlPanel,
+    hideCurlPanel,
+    handleCurlImport,
   } = useConfig(id, data)
   // To prevent prompt editor in body not update data.
   if (!isDataReady)
@@ -64,14 +70,25 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
         <Field
           title={t(`${i18nPrefix}.api`)}
           operations={
-            <div
-              onClick={showAuthorization}
-              className={cn(!readOnly && 'cursor-pointer hover:bg-gray-50', 'flex items-center h-6 space-x-1 px-2 rounded-md ')}
-            >
-              {!readOnly && <Settings01 className='w-3 h-3 text-gray-500' />}
-              <div className='text-xs font-medium text-gray-500'>
-                {t(`${i18nPrefix}.authorization.authorization`)}
-                <span className='ml-1 text-gray-700'>{t(`${i18nPrefix}.authorization.${inputs.authorization.type}`)}</span>
+            <div className='flex'>
+              <div
+                onClick={showAuthorization}
+                className={cn(!readOnly && 'cursor-pointer hover:bg-gray-50', 'flex items-center h-6 space-x-1 px-2 rounded-md ')}
+              >
+                {!readOnly && <Settings01 className='w-3 h-3 text-gray-500' />}
+                <div className='text-xs font-medium text-gray-500'>
+                  {t(`${i18nPrefix}.authorization.authorization`)}
+                  <span className='ml-1 text-gray-700'>{t(`${i18nPrefix}.authorization.${inputs.authorization.type}`)}</span>
+                </div>
+              </div>
+              <div
+                onClick={showCurlPanel}
+                className={cn(!readOnly && 'cursor-pointer hover:bg-gray-50', 'flex items-center h-6 space-x-1 px-2 rounded-md ')}
+              >
+                {!readOnly && <FileArrow01 className='w-3 h-3 text-gray-500' />}
+                <div className='text-xs font-medium text-gray-500'>
+                  {t(`${i18nPrefix}.curl.title`)}
+                </div>
               </div>
             </div>
           }
@@ -180,7 +197,15 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
           result={<ResultPanel {...runResult} showSteps={false} />}
         />
       )}
-    </div >
+      {(isShowCurlPanel && !readOnly) && (
+        <CurlPanel
+          nodeId={id}
+          isShow
+          onHide={hideCurlPanel}
+          handleCurlImport={handleCurlImport}
+        />
+      )}
+    </div>
   )
 }
 

+ 22 - 0
web/app/components/workflow/nodes/http/use-config.ts

@@ -164,6 +164,23 @@ const useConfig = (id: string, payload: HttpNodeType) => {
     setRunInputData(newPayload)
   }, [setRunInputData])
 
+  // curl import panel
+  const [isShowCurlPanel, {
+    setTrue: showCurlPanel,
+    setFalse: hideCurlPanel,
+  }] = useBoolean(false)
+
+  const handleCurlImport = useCallback((newNode: HttpNodeType) => {
+    const newInputs = produce(inputs, (draft: HttpNodeType) => {
+      draft.method = newNode.method
+      draft.url = newNode.url
+      draft.headers = newNode.headers
+      draft.params = newNode.params
+      draft.body = newNode.body
+    })
+    setInputs(newInputs)
+  }, [inputs, setInputs])
+
   return {
     readOnly,
     isDataReady,
@@ -203,6 +220,11 @@ const useConfig = (id: string, payload: HttpNodeType) => {
     inputVarValues,
     setInputVarValues,
     runResult,
+    // curl import
+    isShowCurlPanel,
+    showCurlPanel,
+    hideCurlPanel,
+    handleCurlImport,
   }
 }
 

+ 4 - 0
web/i18n/en-US/workflow.ts

@@ -408,6 +408,10 @@ const translation = {
         writeLabel: 'Write Timeout',
         writePlaceholder: 'Enter write timeout in seconds',
       },
+      curl: {
+        title: 'Import from cURL',
+        placeholder: 'Paste cURL string here',
+      },
     },
     code: {
       inputVars: 'Input Variables',

+ 4 - 0
web/i18n/zh-Hans/workflow.ts

@@ -408,6 +408,10 @@ const translation = {
         writeLabel: '写入超时',
         writePlaceholder: '输入写入超时(以秒为单位)',
       },
+      curl: {
+        title: '导入cURL',
+        placeholder: '粘贴 cURL 字符串',
+      },
     },
     code: {
       inputVars: '输入变量',