curl-panel.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { useCallback, useState } from 'react'
  4. import { useTranslation } from 'react-i18next'
  5. import { BodyType, type HttpNodeType, Method } from '../types'
  6. import Modal from '@/app/components/base/modal'
  7. import Button from '@/app/components/base/button'
  8. import Toast from '@/app/components/base/toast'
  9. import { useNodesInteractions } from '@/app/components/workflow/hooks'
  10. type Props = {
  11. nodeId: string
  12. isShow: boolean
  13. onHide: () => void
  14. handleCurlImport: (node: HttpNodeType) => void
  15. }
  16. const parseCurl = (curlCommand: string): { node: HttpNodeType | null; error: string | null } => {
  17. if (!curlCommand.trim().toLowerCase().startsWith('curl'))
  18. return { node: null, error: 'Invalid cURL command. Command must start with "curl".' }
  19. const node: Partial<HttpNodeType> = {
  20. title: 'HTTP Request',
  21. desc: 'Imported from cURL',
  22. method: Method.get,
  23. url: '',
  24. headers: '',
  25. params: '',
  26. body: { type: BodyType.none, data: '' },
  27. }
  28. const args = curlCommand.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []
  29. for (let i = 1; i < args.length; i++) {
  30. const arg = args[i].replace(/^['"]|['"]$/g, '')
  31. switch (arg) {
  32. case '-X':
  33. case '--request':
  34. if (i + 1 >= args.length)
  35. return { node: null, error: 'Missing HTTP method after -X or --request.' }
  36. node.method = (args[++i].replace(/^['"]|['"]$/g, '') as Method) || Method.get
  37. break
  38. case '-H':
  39. case '--header':
  40. if (i + 1 >= args.length)
  41. return { node: null, error: 'Missing header value after -H or --header.' }
  42. node.headers += (node.headers ? '\n' : '') + args[++i].replace(/^['"]|['"]$/g, '')
  43. break
  44. case '-d':
  45. case '--data':
  46. case '--data-raw':
  47. case '--data-binary':
  48. if (i + 1 >= args.length)
  49. return { node: null, error: 'Missing data value after -d, --data, --data-raw, or --data-binary.' }
  50. node.body = { type: BodyType.rawText, data: args[++i].replace(/^['"]|['"]$/g, '') }
  51. break
  52. case '-F':
  53. case '--form': {
  54. if (i + 1 >= args.length)
  55. return { node: null, error: 'Missing form data after -F or --form.' }
  56. if (node.body?.type !== BodyType.formData)
  57. node.body = { type: BodyType.formData, data: '' }
  58. const formData = args[++i].replace(/^['"]|['"]$/g, '')
  59. const [key, ...valueParts] = formData.split('=')
  60. if (!key)
  61. return { node: null, error: 'Invalid form data format.' }
  62. let value = valueParts.join('=')
  63. // To support command like `curl -F "file=@/path/to/file;type=application/zip"`
  64. // the `;type=application/zip` should translate to `Content-Type: application/zip`
  65. const typeMatch = value.match(/^(.+?);type=(.+)$/)
  66. if (typeMatch) {
  67. const [, actualValue, mimeType] = typeMatch
  68. value = actualValue
  69. node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}`
  70. }
  71. node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}`
  72. break
  73. }
  74. case '--json':
  75. if (i + 1 >= args.length)
  76. return { node: null, error: 'Missing JSON data after --json.' }
  77. node.body = { type: BodyType.json, data: args[++i].replace(/^['"]|['"]$/g, '') }
  78. break
  79. default:
  80. if (arg.startsWith('http') && !node.url)
  81. node.url = arg
  82. break
  83. }
  84. }
  85. if (!node.url)
  86. return { node: null, error: 'Missing URL or url not start with http.' }
  87. // Extract query params from URL
  88. const urlParts = node.url?.split('?') || []
  89. if (urlParts.length > 1) {
  90. node.url = urlParts[0]
  91. node.params = urlParts[1].replace(/&/g, '\n').replace(/=/g, ': ')
  92. }
  93. return { node: node as HttpNodeType, error: null }
  94. }
  95. const CurlPanel: FC<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => {
  96. const [inputString, setInputString] = useState('')
  97. const { handleNodeSelect } = useNodesInteractions()
  98. const { t } = useTranslation()
  99. const handleSave = useCallback(() => {
  100. const { node, error } = parseCurl(inputString)
  101. if (error) {
  102. Toast.notify({
  103. type: 'error',
  104. message: error,
  105. })
  106. return
  107. }
  108. if (!node)
  109. return
  110. onHide()
  111. handleCurlImport(node)
  112. // Close the panel then open it again to make the panel re-render
  113. handleNodeSelect(nodeId, true)
  114. setTimeout(() => {
  115. handleNodeSelect(nodeId)
  116. }, 0)
  117. }, [onHide, nodeId, inputString, handleNodeSelect, handleCurlImport])
  118. return (
  119. <Modal
  120. title={t('workflow.nodes.http.curl.title')}
  121. isShow={isShow}
  122. onClose={onHide}
  123. className='!w-[400px] !max-w-[400px] !p-4'
  124. >
  125. <div>
  126. <textarea
  127. value={inputString}
  128. 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'
  129. onChange={e => setInputString(e.target.value)}
  130. placeholder={t('workflow.nodes.http.curl.placeholder')!}
  131. />
  132. </div>
  133. <div className='mt-4 flex justify-end space-x-2'>
  134. <Button className='!w-[95px]' onClick={onHide} >{t('common.operation.cancel')}</Button>
  135. <Button className='!w-[95px]' variant='primary' onClick={handleSave} > {t('common.operation.save')}</Button>
  136. </div>
  137. </Modal>
  138. )
  139. }
  140. export default React.memo(CurlPanel)