curl-panel.tsx 5.5 KB

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