mirror of https://github.com/langgenius/dify.git
fix(webhook-trigger): request array type adjustment (#25005)
This commit is contained in:
parent
1d1bb9451e
commit
d522350c99
|
|
@ -28,7 +28,7 @@ const getTriggerIcon = (trigger: AppTrigger) => {
|
|||
<div className="absolute -left-0.5 -top-0.5 h-1.5 w-1.5 rounded-sm border border-black/15 bg-green-500" />
|
||||
)
|
||||
}
|
||||
else {
|
||||
else {
|
||||
return (
|
||||
<div className="absolute -left-0.5 -top-0.5 h-1.5 w-1.5 rounded-sm border border-components-badge-status-light-disabled-border-inner bg-components-badge-status-light-disabled-bg shadow-status-indicator-gray-shadow" />
|
||||
)
|
||||
|
|
@ -96,7 +96,7 @@ function TriggerCard({ appInfo }: ITriggerCardProps) {
|
|||
})
|
||||
invalidateAppTriggers(appId)
|
||||
}
|
||||
catch (error) {
|
||||
catch (error) {
|
||||
console.error('Failed to update trigger status:', error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const RunMode = memo(() => {
|
|||
if (option.type === 'user_input') {
|
||||
handleWorkflowStartRunInWorkflow()
|
||||
}
|
||||
else {
|
||||
else {
|
||||
// Placeholder for trigger-specific execution logic for schedule, webhook, plugin types
|
||||
console.log('TODO: Handle trigger execution for type:', option.type, 'nodeId:', option.nodeId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export const useDynamicTestRunOptions = (): TestRunOptions => {
|
|||
/>
|
||||
)
|
||||
}
|
||||
else if (toolIcon && typeof toolIcon === 'object' && 'content' in toolIcon) {
|
||||
else if (toolIcon && typeof toolIcon === 'object' && 'content' in toolIcon) {
|
||||
icon = (
|
||||
<AppIcon
|
||||
className="!h-6 !w-6 rounded-lg border-[0.5px] border-white/2 shadow-md"
|
||||
|
|
@ -112,7 +112,7 @@ export const useDynamicTestRunOptions = (): TestRunOptions => {
|
|||
/>
|
||||
)
|
||||
}
|
||||
else {
|
||||
else {
|
||||
icon = (
|
||||
<div className="bg-util-colors-white-white-500 flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 text-white shadow-md">
|
||||
<span className="text-xs font-medium text-text-tertiary">P</span>
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export const useNodesInteractions = () => {
|
|||
try {
|
||||
return checkNestedParallelLimit(nodes, edges, parentNodeId)
|
||||
}
|
||||
catch (error: any) {
|
||||
catch (error: any) {
|
||||
if (handleStartNodeMissingError(error, operationKey))
|
||||
return false // Operation blocked but gracefully handled
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const Field: FC<Props> = ({
|
|||
<div className={cn(className, inline && 'flex w-full items-center justify-between')}>
|
||||
<div
|
||||
onClick={() => supportFold && toggleFold()}
|
||||
className={cn('sticky top-0 z-10 flex items-center justify-between bg-components-panel-bg', supportFold && 'cursor-pointer')}>
|
||||
className={cn('flex items-center justify-between', supportFold && 'cursor-pointer')}>
|
||||
<div className='flex h-6 items-center'>
|
||||
<div className={cn(isSubTitle ? 'system-xs-medium-uppercase text-text-tertiary' : 'system-sm-semibold-uppercase text-text-secondary')}>
|
||||
{title} {required && <span className='text-text-destructive'>*</span>}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ describe('Monthly Multi-Select Execution Time Calculator', () => {
|
|||
time: '10:30 AM',
|
||||
},
|
||||
timezone: 'UTC',
|
||||
id: 'test',
|
||||
id: 'test',
|
||||
type: 'trigger-schedule',
|
||||
data: {},
|
||||
position: { x: 0, y: 0 },
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ describe('Weekly Schedule Time Logic Tests', () => {
|
|||
time: '2:00 PM',
|
||||
},
|
||||
timezone: 'UTC',
|
||||
}
|
||||
}
|
||||
|
||||
const weeklyTimes = getNextExecutionTimes(weeklyConfig, 1)
|
||||
const dailyTimes = getNextExecutionTimes(dailyConfig, 1)
|
||||
|
|
@ -387,7 +387,7 @@ describe('Weekly Schedule Time Logic Tests', () => {
|
|||
time: '2:00 PM',
|
||||
},
|
||||
timezone: 'UTC',
|
||||
}
|
||||
}
|
||||
|
||||
const weeklyTimes = getNextExecutionTimes(weeklyConfig, 1)
|
||||
const dailyTimes = getNextExecutionTimes(dailyConfig, 1)
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
|
|||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: payload.timezone })
|
||||
}
|
||||
catch {
|
||||
catch {
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.invalidTimezone')
|
||||
}
|
||||
}
|
||||
|
|
@ -138,13 +138,13 @@ const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
|
|||
if (payload.mode === 'cron') {
|
||||
if (!payload.cron_expression || payload.cron_expression.trim() === '')
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.cronExpression') })
|
||||
else if (!isValidCronExpression(payload.cron_expression))
|
||||
else if (!isValidCronExpression(payload.cron_expression))
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.invalidCronExpression')
|
||||
}
|
||||
else if (payload.mode === 'visual') {
|
||||
else if (payload.mode === 'visual') {
|
||||
if (!payload.frequency)
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.frequency') })
|
||||
else
|
||||
else
|
||||
errorMessages = validateVisualConfig(payload, t)
|
||||
}
|
||||
}
|
||||
|
|
@ -154,7 +154,7 @@ const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
|
|||
if (nextTimes.length === 0)
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.noValidExecutionTime')
|
||||
}
|
||||
catch {
|
||||
catch {
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.executionTimeCalculationError')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const matchesField = (value: number, pattern: string, min: number, max: number):
|
|||
if (range === '*') {
|
||||
return value % stepValue === min % stepValue
|
||||
}
|
||||
else {
|
||||
else {
|
||||
const rangeStart = Number.parseInt(range, 10)
|
||||
if (Number.isNaN(rangeStart)) return false
|
||||
return value >= rangeStart && (value - rangeStart) % stepValue === 0
|
||||
|
|
@ -96,15 +96,15 @@ const matchesCron = (
|
|||
return matchesField(currentDay, dayOfMonth, 1, 31)
|
||||
|| matchesField(currentDayOfWeek, dayOfWeek, 0, 6)
|
||||
}
|
||||
else if (dayOfMonthSpecified) {
|
||||
else if (dayOfMonthSpecified) {
|
||||
// Only day of month specified
|
||||
return matchesField(currentDay, dayOfMonth, 1, 31)
|
||||
}
|
||||
else if (dayOfWeekSpecified) {
|
||||
else if (dayOfWeekSpecified) {
|
||||
// Only day of week specified
|
||||
return matchesField(currentDayOfWeek, dayOfWeek, 0, 6)
|
||||
}
|
||||
else {
|
||||
else {
|
||||
// Both are *, matches any day
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,20 +5,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import GenericTable from './generic-table'
|
||||
import type { ColumnConfig, GenericTableRow } from './generic-table'
|
||||
import type { ParameterType, WebhookParameter } from '../types'
|
||||
|
||||
const normalizeParamType = (type: string): ParameterType => {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
case 'array':
|
||||
case 'object':
|
||||
case 'file':
|
||||
return type
|
||||
default:
|
||||
return 'string'
|
||||
}
|
||||
}
|
||||
import { createParameterTypeOptions, normalizeParameterType } from '../utils/parameter-type-utils'
|
||||
|
||||
type ParameterTableProps = {
|
||||
title: string
|
||||
|
|
@ -43,59 +30,7 @@ const ParameterTable: FC<ParameterTableProps> = ({
|
|||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const resolveTypeOptions = (): { name: string; value: ParameterType }[] => {
|
||||
if (!isRequestBody) {
|
||||
return [
|
||||
{ name: 'String', value: 'string' },
|
||||
]
|
||||
}
|
||||
|
||||
const ct = (contentType || '').toLowerCase()
|
||||
// application/json -> all JSON-friendly types
|
||||
if (ct === 'application/json') {
|
||||
return [
|
||||
{ name: 'String', value: 'string' },
|
||||
{ name: 'Number', value: 'number' },
|
||||
{ name: 'Boolean', value: 'boolean' },
|
||||
{ name: 'Array', value: 'array' },
|
||||
{ name: 'Object', value: 'object' },
|
||||
]
|
||||
}
|
||||
// text/plain -> plain text only
|
||||
if (ct === 'text/plain') {
|
||||
return [
|
||||
{ name: 'String', value: 'string' },
|
||||
]
|
||||
}
|
||||
// x-www-form-urlencoded and forms -> simple key-value pairs
|
||||
if (ct === 'application/x-www-form-urlencoded' || ct === 'forms') {
|
||||
return [
|
||||
{ name: 'String', value: 'string' },
|
||||
{ name: 'Number', value: 'number' },
|
||||
{ name: 'Boolean', value: 'boolean' },
|
||||
]
|
||||
}
|
||||
// multipart/form-data -> allow file additionally
|
||||
if (ct === 'multipart/form-data') {
|
||||
return [
|
||||
{ name: 'String', value: 'string' },
|
||||
{ name: 'Number', value: 'number' },
|
||||
{ name: 'Boolean', value: 'boolean' },
|
||||
{ name: 'File', value: 'file' },
|
||||
]
|
||||
}
|
||||
|
||||
// Fallback: all json-like types
|
||||
return [
|
||||
{ name: 'String', value: 'string' },
|
||||
{ name: 'Number', value: 'number' },
|
||||
{ name: 'Boolean', value: 'boolean' },
|
||||
{ name: 'Array', value: 'array' },
|
||||
{ name: 'Object', value: 'object' },
|
||||
]
|
||||
}
|
||||
|
||||
const typeOptions = resolveTypeOptions()
|
||||
const typeOptions = createParameterTypeOptions(contentType, isRequestBody)
|
||||
|
||||
// Define columns based on component type - matching prototype design
|
||||
const columns: ColumnConfig[] = [
|
||||
|
|
@ -125,10 +60,7 @@ const ParameterTable: FC<ParameterTableProps> = ({
|
|||
]
|
||||
|
||||
// Choose sensible default type for new rows according to content type
|
||||
const defaultTypeValue = ((): ParameterType => {
|
||||
const first = typeOptions[0]?.value
|
||||
return first || 'string'
|
||||
})()
|
||||
const defaultTypeValue: ParameterType = typeOptions[0]?.value || 'string'
|
||||
|
||||
// Empty row template for new rows
|
||||
const emptyRowData: GenericTableRow = {
|
||||
|
|
@ -151,7 +83,7 @@ const ParameterTable: FC<ParameterTableProps> = ({
|
|||
.filter(row => typeof row.key === 'string' && (row.key as string).trim() !== '')
|
||||
.map(row => ({
|
||||
name: String(row.key),
|
||||
type: isTextPlain ? 'string' : normalizeParamType((row.type as string) || 'string'),
|
||||
type: isTextPlain ? 'string' : normalizeParameterType((row.type as string) || 'string'),
|
||||
required: Boolean(row.required),
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { BlockEnum } from '../../types'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { WebhookTriggerNodeType } from './types'
|
||||
import { isValidParameterType } from './utils/parameter-type-utils'
|
||||
import { ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
|
||||
|
||||
const nodeDefault: NodeDefault<WebhookTriggerNodeType> = {
|
||||
|
|
@ -24,7 +25,34 @@ const nodeDefault: NodeDefault<WebhookTriggerNodeType> = {
|
|||
: ALL_COMPLETION_AVAILABLE_BLOCKS
|
||||
return nodes.filter(type => type !== BlockEnum.Start)
|
||||
},
|
||||
checkValid(_payload: WebhookTriggerNodeType, _t: any) {
|
||||
checkValid(payload: WebhookTriggerNodeType, t: any) {
|
||||
// Validate webhook configuration
|
||||
if (!payload.webhook_url) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('workflow.nodes.webhook.validation.webhookUrlRequired'),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parameter types for params and body
|
||||
const parametersWithTypes = [
|
||||
...(payload.params || []),
|
||||
...(payload.body || []),
|
||||
]
|
||||
|
||||
for (const param of parametersWithTypes) {
|
||||
// Validate parameter type is valid
|
||||
if (!isValidParameterType(param.type)) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('workflow.nodes.webhook.validation.invalidParameterType', {
|
||||
name: param.name,
|
||||
type: param.type,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
errorMessage: '',
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
|
|||
placeholder={t(`${i18nPrefix}.noBodyParameters`)}
|
||||
showType={true}
|
||||
isRequestBody={true}
|
||||
contentType={inputs['content-type']}
|
||||
contentType={inputs.content_type}
|
||||
/>
|
||||
|
||||
<Split />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,18 @@ import type { CommonNodeType, InputVar } from '@/app/components/workflow/types'
|
|||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'
|
||||
|
||||
export type ParameterType = 'string' | 'number' | 'boolean' | 'array' | 'object' | 'file'
|
||||
export type ParameterType = 'string' | 'number' | 'boolean' | 'array[string]' | 'array[number]' | 'array[boolean]' | 'array[object]' | 'object' | 'file'
|
||||
|
||||
export type ArrayElementType = 'string' | 'number' | 'boolean' | 'object'
|
||||
|
||||
export const isArrayType = (type: ParameterType): type is `array[${ArrayElementType}]` => {
|
||||
return type.startsWith('array[') && type.endsWith(']')
|
||||
}
|
||||
|
||||
export const getArrayElementType = (arrayType: `array[${ArrayElementType}]`): ArrayElementType => {
|
||||
const match = arrayType.match(/^array\[(.+)\]$/)
|
||||
return (match?.[1] as ArrayElementType) || 'string'
|
||||
}
|
||||
|
||||
export type WebhookParameter = {
|
||||
name: string
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useCallback } from 'react'
|
||||
import produce from 'immer'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
|
||||
import type { HttpMethod, ParameterType, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
|
||||
import { getArrayElementType, isArrayType } from './types'
|
||||
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
|
|
@ -30,13 +32,31 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
|||
}, [inputs, setInputs])
|
||||
|
||||
// Helper function to convert ParameterType to InputVarType
|
||||
const toInputVarType = useCallback((type: string): InputVarType => {
|
||||
const toInputVarType = useCallback((type: ParameterType): InputVarType => {
|
||||
// Handle specific array types
|
||||
if (isArrayType(type)) {
|
||||
const elementType = getArrayElementType(type)
|
||||
switch (elementType) {
|
||||
case 'string':
|
||||
return InputVarType.textInput
|
||||
case 'number':
|
||||
return InputVarType.number
|
||||
case 'boolean':
|
||||
return InputVarType.checkbox
|
||||
case 'object':
|
||||
return InputVarType.jsonObject
|
||||
default:
|
||||
return InputVarType.textInput
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-array types
|
||||
const typeMap: Record<string, InputVarType> = {
|
||||
string: InputVarType.textInput,
|
||||
number: InputVarType.number,
|
||||
boolean: InputVarType.checkbox,
|
||||
array: InputVarType.textInput, // Arrays as text for now
|
||||
object: InputVarType.jsonObject,
|
||||
file: InputVarType.singleFile,
|
||||
}
|
||||
return typeMap[type] || InputVarType.textInput
|
||||
}, [])
|
||||
|
|
@ -49,12 +69,12 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
|||
draft.variables = []
|
||||
|
||||
if(hasDuplicateStr(newData.map(item => item.name))) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('appDebug.varKeyError.keyAlreadyExists', {
|
||||
key: t('appDebug.variableConfig.varName'),
|
||||
}),
|
||||
})
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('appDebug.varKeyError.keyAlreadyExists', {
|
||||
key: t('appDebug.variableConfig.varName'),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +96,7 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
|||
|
||||
if (existingVarIndex >= 0)
|
||||
draft.variables[existingVarIndex] = newVar
|
||||
else
|
||||
else
|
||||
draft.variables.push(newVar)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import {
|
||||
createParameterTypeOptions,
|
||||
getAvailableParameterTypes,
|
||||
getParameterTypeDisplayName,
|
||||
isValidParameterType,
|
||||
normalizeParameterType,
|
||||
validateParameterValue,
|
||||
} from './parameter-type-utils'
|
||||
|
||||
describe('Parameter Type Utils', () => {
|
||||
describe('isValidParameterType', () => {
|
||||
it('should validate specific array types', () => {
|
||||
expect(isValidParameterType('array[string]')).toBe(true)
|
||||
expect(isValidParameterType('array[number]')).toBe(true)
|
||||
expect(isValidParameterType('array[boolean]')).toBe(true)
|
||||
expect(isValidParameterType('array[object]')).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate basic types', () => {
|
||||
expect(isValidParameterType('string')).toBe(true)
|
||||
expect(isValidParameterType('number')).toBe(true)
|
||||
expect(isValidParameterType('boolean')).toBe(true)
|
||||
expect(isValidParameterType('object')).toBe(true)
|
||||
expect(isValidParameterType('file')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid types', () => {
|
||||
expect(isValidParameterType('array')).toBe(false)
|
||||
expect(isValidParameterType('invalid')).toBe(false)
|
||||
expect(isValidParameterType('array[invalid]')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeParameterType', () => {
|
||||
it('should normalize valid types', () => {
|
||||
expect(normalizeParameterType('string')).toBe('string')
|
||||
expect(normalizeParameterType('array[string]')).toBe('array[string]')
|
||||
})
|
||||
|
||||
it('should migrate legacy array type', () => {
|
||||
expect(normalizeParameterType('array')).toBe('array[string]')
|
||||
})
|
||||
|
||||
it('should default to string for invalid types', () => {
|
||||
expect(normalizeParameterType('invalid')).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getParameterTypeDisplayName', () => {
|
||||
it('should return correct display names for array types', () => {
|
||||
expect(getParameterTypeDisplayName('array[string]')).toBe('Array[String]')
|
||||
expect(getParameterTypeDisplayName('array[number]')).toBe('Array[Number]')
|
||||
expect(getParameterTypeDisplayName('array[boolean]')).toBe('Array[Boolean]')
|
||||
expect(getParameterTypeDisplayName('array[object]')).toBe('Array[Object]')
|
||||
})
|
||||
|
||||
it('should return correct display names for basic types', () => {
|
||||
expect(getParameterTypeDisplayName('string')).toBe('String')
|
||||
expect(getParameterTypeDisplayName('number')).toBe('Number')
|
||||
expect(getParameterTypeDisplayName('boolean')).toBe('Boolean')
|
||||
expect(getParameterTypeDisplayName('object')).toBe('Object')
|
||||
expect(getParameterTypeDisplayName('file')).toBe('File')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateParameterValue', () => {
|
||||
it('should validate string values', () => {
|
||||
expect(validateParameterValue('test', 'string')).toBe(true)
|
||||
expect(validateParameterValue('', 'string')).toBe(true)
|
||||
expect(validateParameterValue(123, 'string')).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate number values', () => {
|
||||
expect(validateParameterValue(123, 'number')).toBe(true)
|
||||
expect(validateParameterValue(123.45, 'number')).toBe(true)
|
||||
expect(validateParameterValue('abc', 'number')).toBe(false)
|
||||
expect(validateParameterValue(Number.NaN, 'number')).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate boolean values', () => {
|
||||
expect(validateParameterValue(true, 'boolean')).toBe(true)
|
||||
expect(validateParameterValue(false, 'boolean')).toBe(true)
|
||||
expect(validateParameterValue('true', 'boolean')).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate array values', () => {
|
||||
expect(validateParameterValue(['a', 'b'], 'array[string]')).toBe(true)
|
||||
expect(validateParameterValue([1, 2, 3], 'array[number]')).toBe(true)
|
||||
expect(validateParameterValue([true, false], 'array[boolean]')).toBe(true)
|
||||
expect(validateParameterValue([{ key: 'value' }], 'array[object]')).toBe(true)
|
||||
expect(validateParameterValue(['a', 1], 'array[string]')).toBe(false)
|
||||
expect(validateParameterValue('not an array', 'array[string]')).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate object values', () => {
|
||||
expect(validateParameterValue({ key: 'value' }, 'object')).toBe(true)
|
||||
expect(validateParameterValue({}, 'object')).toBe(true)
|
||||
expect(validateParameterValue(null, 'object')).toBe(false)
|
||||
expect(validateParameterValue([], 'object')).toBe(false)
|
||||
expect(validateParameterValue('string', 'object')).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate file values', () => {
|
||||
const mockFile = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
expect(validateParameterValue(mockFile, 'file')).toBe(true)
|
||||
expect(validateParameterValue({ name: 'file.txt' }, 'file')).toBe(true)
|
||||
expect(validateParameterValue('not a file', 'file')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for invalid types', () => {
|
||||
expect(validateParameterValue('test', 'invalid' as any)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAvailableParameterTypes', () => {
|
||||
it('should return only string for non-request body', () => {
|
||||
const types = getAvailableParameterTypes('application/json', false)
|
||||
expect(types).toEqual(['string'])
|
||||
})
|
||||
|
||||
it('should return all types for application/json', () => {
|
||||
const types = getAvailableParameterTypes('application/json', true)
|
||||
expect(types).toContain('string')
|
||||
expect(types).toContain('number')
|
||||
expect(types).toContain('boolean')
|
||||
expect(types).toContain('array[string]')
|
||||
expect(types).toContain('array[number]')
|
||||
expect(types).toContain('array[boolean]')
|
||||
expect(types).toContain('array[object]')
|
||||
expect(types).toContain('object')
|
||||
})
|
||||
|
||||
it('should include file type for multipart/form-data', () => {
|
||||
const types = getAvailableParameterTypes('multipart/form-data', true)
|
||||
expect(types).toContain('file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createParameterTypeOptions', () => {
|
||||
it('should create options with display names', () => {
|
||||
const options = createParameterTypeOptions('application/json', true)
|
||||
const stringOption = options.find(opt => opt.value === 'string')
|
||||
const arrayStringOption = options.find(opt => opt.value === 'array[string]')
|
||||
|
||||
expect(stringOption?.name).toBe('String')
|
||||
expect(arrayStringOption?.name).toBe('Array[String]')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import type { ArrayElementType, ParameterType } from '../types'
|
||||
|
||||
// Constants for better maintainability and reusability
|
||||
const BASIC_TYPES = ['string', 'number', 'boolean', 'object', 'file'] as const
|
||||
const ARRAY_ELEMENT_TYPES = ['string', 'number', 'boolean', 'object'] as const
|
||||
|
||||
// Generate all valid parameter types programmatically
|
||||
const VALID_PARAMETER_TYPES: readonly ParameterType[] = [
|
||||
...BASIC_TYPES,
|
||||
...ARRAY_ELEMENT_TYPES.map(type => `array[${type}]` as const),
|
||||
] as const
|
||||
|
||||
// Type display name mappings
|
||||
const TYPE_DISPLAY_NAMES: Record<ParameterType, string> = {
|
||||
'string': 'String',
|
||||
'number': 'Number',
|
||||
'boolean': 'Boolean',
|
||||
'object': 'Object',
|
||||
'file': 'File',
|
||||
'array[string]': 'Array[String]',
|
||||
'array[number]': 'Array[Number]',
|
||||
'array[boolean]': 'Array[Boolean]',
|
||||
'array[object]': 'Array[Object]',
|
||||
} as const
|
||||
|
||||
// Content type configurations
|
||||
const CONTENT_TYPE_CONFIGS = {
|
||||
'application/json': {
|
||||
supportedTypes: [...BASIC_TYPES.filter(t => t !== 'file'), ...ARRAY_ELEMENT_TYPES.map(t => `array[${t}]` as const)],
|
||||
description: 'JSON supports all types including arrays',
|
||||
},
|
||||
'text/plain': {
|
||||
supportedTypes: ['string'] as const,
|
||||
description: 'Plain text only supports string',
|
||||
},
|
||||
'application/x-www-form-urlencoded': {
|
||||
supportedTypes: ['string', 'number', 'boolean'] as const,
|
||||
description: 'Form data supports basic types',
|
||||
},
|
||||
'forms': {
|
||||
supportedTypes: ['string', 'number', 'boolean'] as const,
|
||||
description: 'Form data supports basic types',
|
||||
},
|
||||
'multipart/form-data': {
|
||||
supportedTypes: ['string', 'number', 'boolean', 'file'] as const,
|
||||
description: 'Multipart supports basic types plus files',
|
||||
},
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Type guard to check if a string is a valid parameter type
|
||||
*/
|
||||
export const isValidParameterType = (type: string): type is ParameterType => {
|
||||
return (VALID_PARAMETER_TYPES as readonly string[]).includes(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe helper to check if a string is a valid array element type
|
||||
*/
|
||||
const isValidArrayElementType = (type: string): type is ArrayElementType => {
|
||||
return (ARRAY_ELEMENT_TYPES as readonly string[]).includes(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe helper to check if a string is a valid basic type
|
||||
*/
|
||||
const isValidBasicType = (type: string): type is Exclude<ParameterType, `array[${ArrayElementType}]`> => {
|
||||
return (BASIC_TYPES as readonly string[]).includes(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes parameter type from various input formats to the new type system
|
||||
* Handles legacy 'array' type and malformed inputs gracefully
|
||||
*/
|
||||
export const normalizeParameterType = (input: string | undefined | null): ParameterType => {
|
||||
if (!input || typeof input !== 'string')
|
||||
return 'string'
|
||||
|
||||
const trimmed = input.trim().toLowerCase()
|
||||
|
||||
// Handle legacy array type
|
||||
if (trimmed === 'array')
|
||||
return 'array[string]' // Default to string array for backward compatibility
|
||||
|
||||
// Handle specific array types
|
||||
if (trimmed.startsWith('array[') && trimmed.endsWith(']')) {
|
||||
const elementType = trimmed.slice(6, -1) // Extract content between 'array[' and ']'
|
||||
|
||||
if (isValidArrayElementType(elementType))
|
||||
return `array[${elementType}]`
|
||||
|
||||
// Invalid array element type, default to string array
|
||||
return 'array[string]'
|
||||
}
|
||||
|
||||
// Handle basic types
|
||||
if (isValidBasicType(trimmed))
|
||||
return trimmed
|
||||
|
||||
// Fallback to string for unknown types
|
||||
return 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets display name for parameter types in UI components
|
||||
*/
|
||||
export const getParameterTypeDisplayName = (type: ParameterType): string => {
|
||||
return TYPE_DISPLAY_NAMES[type]
|
||||
}
|
||||
|
||||
// Type validation functions for better reusability
|
||||
const validators = {
|
||||
string: (value: unknown): value is string => typeof value === 'string',
|
||||
number: (value: unknown): value is number => typeof value === 'number' && !isNaN(value),
|
||||
boolean: (value: unknown): value is boolean => typeof value === 'boolean',
|
||||
object: (value: unknown): value is object =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value),
|
||||
file: (value: unknown): value is File =>
|
||||
value instanceof File || (typeof value === 'object' && value !== null),
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Validates array elements based on element type
|
||||
*/
|
||||
const validateArrayElements = (value: unknown[], elementType: ArrayElementType): boolean => {
|
||||
const validator = validators[elementType]
|
||||
return value.every(item => validator(item))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates parameter value against its declared type
|
||||
* Provides runtime type checking for webhook parameters
|
||||
*/
|
||||
export const validateParameterValue = (value: unknown, type: ParameterType): boolean => {
|
||||
// Handle basic types
|
||||
if (type in validators) {
|
||||
const validator = validators[type as keyof typeof validators]
|
||||
return validator(value)
|
||||
}
|
||||
|
||||
// Handle array types
|
||||
if (type.startsWith('array[') && type.endsWith(']')) {
|
||||
if (!Array.isArray(value)) return false
|
||||
|
||||
const elementType = type.slice(6, -1)
|
||||
return isValidArrayElementType(elementType) && validateArrayElements(value, elementType)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets available parameter types based on content type
|
||||
* Provides context-aware type filtering for different webhook content types
|
||||
*/
|
||||
export const getAvailableParameterTypes = (contentType?: string, isRequestBody = false): ParameterType[] => {
|
||||
if (!isRequestBody) {
|
||||
// Query parameters and headers are always strings
|
||||
return ['string']
|
||||
}
|
||||
|
||||
const normalizedContentType = (contentType || '').toLowerCase()
|
||||
const configKey = normalizedContentType in CONTENT_TYPE_CONFIGS
|
||||
? normalizedContentType as keyof typeof CONTENT_TYPE_CONFIGS
|
||||
: 'application/json'
|
||||
|
||||
const config = CONTENT_TYPE_CONFIGS[configKey]
|
||||
return [...config.supportedTypes]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates type options for UI select components
|
||||
*/
|
||||
export const createParameterTypeOptions = (contentType?: string, isRequestBody = false) => {
|
||||
const availableTypes = getAvailableParameterTypes(contentType, isRequestBody)
|
||||
|
||||
return availableTypes.map(type => ({
|
||||
name: getParameterTypeDisplayName(type),
|
||||
value: type,
|
||||
}))
|
||||
}
|
||||
|
|
@ -134,7 +134,7 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => {
|
|||
}
|
||||
})
|
||||
}
|
||||
else {
|
||||
else {
|
||||
// Leaf node - add iteration/loop children if any
|
||||
if (root.data.type === BlockEnum.Iteration)
|
||||
list.push(...nodes.filter(node => node.parentId === root.id))
|
||||
|
|
|
|||
Loading…
Reference in New Issue