From dce3e7f0f2e0e218deafb654f7bf066b444ce63d Mon Sep 17 00:00:00 2001 From: Md Afiq Iskandar Date: Mon, 19 May 2025 13:43:04 +0800 Subject: [PATCH] Add API Call Node Configuration and Integration - Introduced a new component, ApiNodeConfiguration, for configuring API call nodes within the process builder. - Enhanced ProcessBuilderComponents to include the new API Call node type with default properties. - Implemented ApiCallNode in ProcessFlowNodes for rendering API call nodes with relevant details. - Added a backend endpoint for testing API node configurations, allowing users to validate API calls without executing the entire process. - Updated VariableManager to default to global scope for new variables, ensuring consistency in variable management. - Improved the overall process builder experience by integrating API call functionality and enhancing variable handling. --- .../process-flow/ApiNodeConfiguration.vue | 410 ++++++++++++++++++ .../process-flow/ProcessBuilderComponents.vue | 20 + components/process-flow/ProcessFlowNodes.js | 66 ++- components/process-flow/VariableManager.vue | 65 +-- pages/process-builder/index.vue | 24 + server/api/process/test-api-node.post.js | 73 ++++ services/apiNodeService.js | 173 ++++++++ stores/variableStore.js | 44 +- 8 files changed, 806 insertions(+), 69 deletions(-) create mode 100644 components/process-flow/ApiNodeConfiguration.vue create mode 100644 server/api/process/test-api-node.post.js create mode 100644 services/apiNodeService.js diff --git a/components/process-flow/ApiNodeConfiguration.vue b/components/process-flow/ApiNodeConfiguration.vue new file mode 100644 index 0000000..e32c927 --- /dev/null +++ b/components/process-flow/ApiNodeConfiguration.vue @@ -0,0 +1,410 @@ + + + + + \ No newline at end of file diff --git a/components/process-flow/ProcessBuilderComponents.vue b/components/process-flow/ProcessBuilderComponents.vue index 47f87d7..4b923de 100644 --- a/components/process-flow/ProcessBuilderComponents.vue +++ b/components/process-flow/ProcessBuilderComponents.vue @@ -99,6 +99,26 @@ const availableComponents = [ } } }, + { + type: 'api', + name: 'API Call', + category: 'Core', + icon: 'material-symbols:api', + description: 'Make external API calls', + defaultProps: { + label: 'API Call', + data: { + description: 'External API call', + apiMethod: 'GET', + apiUrl: '', + requestBody: '', + headers: '{ "Content-Type": "application/json" }', + outputVariable: 'apiResponse', + continueOnError: false, + errorVariable: 'apiError' + } + } + }, { type: 'gateway', name: 'Decision Point', diff --git a/components/process-flow/ProcessFlowNodes.js b/components/process-flow/ProcessFlowNodes.js index bcac2e0..d522655 100644 --- a/components/process-flow/ProcessFlowNodes.js +++ b/components/process-flow/ProcessFlowNodes.js @@ -271,6 +271,54 @@ export const ScriptNode = markRaw({ } }); +// API Call node +export const ApiCallNode = markRaw({ + props: ['id', 'type', 'label', 'selected', 'data'], + computed: { + nodeLabel() { + return this.label || (this.data && this.data.label) || 'API Call'; + }, + apiUrl() { + return this.data?.apiUrl || 'No URL specified'; + }, + apiMethod() { + return this.data?.apiMethod || 'GET'; + } + }, + render() { + const badgeContent = h('span', { + class: 'node-badge bg-indigo-100 text-indigo-600 px-1 text-xs rounded' + }, 'API'); + + return h(CustomNode, { + id: this.id, + type: 'api', + label: this.nodeLabel, + selected: this.selected, + data: this.data, + onClick: () => this.$emit('node-click', this.id) + }, { + icon: () => h('i', { class: 'material-icons text-indigo-500' }, 'api'), + badge: () => badgeContent, + default: () => h('div', { class: 'node-details' }, [ + h('p', { class: 'node-description' }, this.data?.description || 'External API call'), + h('div', { class: 'node-api-info' }, [ + h('span', { class: 'node-api-label' }, 'URL: '), + h('span', { + class: 'node-api-value text-indigo-600 font-medium' + }, this.apiUrl) + ]), + h('div', { class: 'node-api-method-info' }, [ + h('span', { class: 'node-api-method-label' }, 'Method: '), + h('span', { + class: 'node-api-method-value' + }, this.apiMethod) + ]) + ]) + }); + } +}); + // Export the node types object to use with Vue Flow export const nodeTypes = markRaw({ task: TaskNode, @@ -278,7 +326,8 @@ export const nodeTypes = markRaw({ end: EndNode, gateway: GatewayNode, form: FormNode, - script: ScriptNode + script: ScriptNode, + api: ApiCallNode }); // Default CSS for the nodes to be imported where needed @@ -297,7 +346,7 @@ export const nodeStyles = ` } /* Base styles for different node types */ -.node-task, .node-form, .node-script { +.node-task, .node-form, .node-script, .node-api { width: 180px; background: white; border-radius: 4px; @@ -394,7 +443,8 @@ export const nodeStyles = ` .node-task .custom-node-title, .node-form .custom-node-title, -.node-script .custom-node-title { +.node-script .custom-node-title, +.node-api .custom-node-title { font-weight: 500; font-size: 11px; display: flex; @@ -461,6 +511,8 @@ export const nodeStyles = ` .node-assignee, .node-form-info, .node-script-info, +.node-api-info, +.node-api-method-info, .node-conditions { display: flex; font-size: 10px; @@ -470,13 +522,17 @@ export const nodeStyles = ` .node-assignee-label, .node-form-label, -.node-script-label { +.node-script-label, +.node-api-label, +.node-api-method-label { font-weight: 500; margin-right: 4px; } .node-form-value, .node-script-value, +.node-api-value, +.node-api-method-value, .node-assignee-value { white-space: nowrap; overflow: hidden; @@ -507,6 +563,7 @@ export const nodeStyles = ` .handle-task-input, .handle-form-input, .handle-script-input, +.handle-api-input, .handle-gateway-input { top: -5px !important; width: 8px !important; @@ -517,6 +574,7 @@ export const nodeStyles = ` .handle-task-output, .handle-form-output, .handle-script-output, +.handle-api-output, .handle-gateway-output { bottom: -5px !important; width: 8px !important; diff --git a/components/process-flow/VariableManager.vue b/components/process-flow/VariableManager.vue index 5246015..8997110 100644 --- a/components/process-flow/VariableManager.vue +++ b/components/process-flow/VariableManager.vue @@ -56,14 +56,6 @@ {{ variable.name }} - - {{ variable.scope }} - {{ variable.type }} @@ -129,12 +121,11 @@ label="Type" :options="[ { label: 'String', value: 'string' }, - { label: 'Number', value: 'number' }, - { label: 'Boolean', value: 'boolean' }, + { label: 'Int', value: 'int' }, + { label: 'Decimal', value: 'decimal' }, { label: 'Object', value: 'object' }, - { label: 'Array', value: 'array' }, - { label: 'Date', value: 'date' }, - { label: 'File', value: 'file' }, + { label: 'DateTime', value: 'datetime' }, + { label: 'Date', value: 'date' } ]" validation="required" :validation-messages="{ @@ -142,21 +133,6 @@ }" /> - - - -
Cancel @@ -199,19 +167,14 @@ const editingVariable = ref(null); const variableForm = ref({ name: "", type: "string", - scope: "process", + scope: "global", description: "", - isRequired: false, }); // Computed const variables = computed(() => { - // This was only returning process variables, let's fix it to return both process and global variables - const allVars = [ - ...variableStore.getAllVariables.process, - ...variableStore.getAllVariables.global, - ]; - return allVars; + // Only return global variables + return variableStore.getAllVariables.global; }); // Methods @@ -223,7 +186,7 @@ const editVariable = (variable) => { const deleteVariable = (variable) => { if (confirm(`Are you sure you want to delete ${variable.name}?`)) { - variableStore.deleteVariable(variable.name, variable.scope); + variableStore.deleteVariable(variable.name, 'global'); } }; @@ -231,9 +194,8 @@ const resetForm = () => { variableForm.value = { name: "", type: "string", - scope: "process", - description: "", - isRequired: false, + scope: "global", + description: "" }; editingVariable.value = null; }; @@ -249,9 +211,8 @@ const saveVariable = async (formData) => { const newVariable = { name: formData.name, type: formData.type, - scope: formData.scope, - description: formData.description, - isRequired: formData.isRequired, + scope: "global", + description: formData.description }; if (editingVariable.value) { @@ -259,7 +220,7 @@ const saveVariable = async (formData) => { variableStore.updateVariable( editingVariable.value.name, newVariable, - newVariable.scope + "global" ); } else { // Add new variable diff --git a/pages/process-builder/index.vue b/pages/process-builder/index.vue index dc43e03..44676bd 100644 --- a/pages/process-builder/index.vue +++ b/pages/process-builder/index.vue @@ -7,6 +7,7 @@ import ProcessFlowCanvas from '~/components/process-flow/ProcessFlowCanvas.vue'; import ProcessBuilderComponents from '~/components/process-flow/ProcessBuilderComponents.vue'; import FormSelector from '~/components/process-flow/FormSelector.vue'; import GatewayConditionManager from '~/components/process-flow/GatewayConditionManager.vue'; +import ApiNodeConfiguration from '~/components/process-flow/ApiNodeConfiguration.vue'; import VariableManager from '~/components/process-flow/VariableManager.vue'; import { onBeforeRouteLeave } from 'vue-router'; @@ -74,6 +75,22 @@ const components = [ iconColor: 'text-purple-500', data: { description: 'Form submission', formName: '' } }, + { + type: 'api', + label: 'API Call', + icon: 'api', + iconColor: 'text-indigo-500', + data: { + description: 'External API call', + apiMethod: 'GET', + apiUrl: '', + requestBody: '', + headers: '{ "Content-Type": "application/json" }', + outputVariable: 'apiResponse', + continueOnError: false, + errorVariable: 'apiError' + } + }, { type: 'gateway', label: 'Decision Point', @@ -724,6 +741,13 @@ const onConditionsUpdated = (conditions) => { />
+ +
+ +
+
{ + try { + // Get request body + const body = await readBody(event); + + // Extract node configuration and process variables + const { nodeConfig, processVariables } = body; + + // Validate input + if (!nodeConfig || !nodeConfig.apiUrl) { + return { + success: false, + error: { + message: 'Invalid API node configuration. Missing apiUrl.' + } + }; + } + + // Structure the variables for the API call + const structuredVariables = { + global: {}, + process: {}, + ...processVariables + }; + + // Categorize variables by scope if they're not already structured + if (!processVariables.global && !processVariables.process) { + Object.entries(processVariables || {}).forEach(([name, value]) => { + // Determine which variables are global based on the presence of an actual variable in the store + // This would usually be handled by the process execution engine + if (name === nodeConfig.outputVariable || name === nodeConfig.errorVariable) { + structuredVariables.global[name] = value; + } else { + structuredVariables.process[name] = value; + } + }); + } + + // Execute the API call + const result = await executeApiCall(nodeConfig, structuredVariables); + + // Update global variables with the result + if (result.success && nodeConfig.outputVariable) { + structuredVariables.global[nodeConfig.outputVariable] = result.data; + } else if (!result.success && nodeConfig.errorVariable) { + structuredVariables.global[nodeConfig.errorVariable] = result.error; + } + + // Add the updated variables to the result + result.variables = structuredVariables; + + // Return the result + return result; + } catch (error) { + // Handle errors + return { + success: false, + error: { + message: error.message || 'An error occurred while testing the API node', + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined + } + }; + } +}); \ No newline at end of file diff --git a/services/apiNodeService.js b/services/apiNodeService.js new file mode 100644 index 0000000..e94e014 --- /dev/null +++ b/services/apiNodeService.js @@ -0,0 +1,173 @@ +/** + * API Node Service + * + * This service handles the execution of API Call nodes in the process flow. + * It supports dynamic variable substitution in URLs, headers, and request bodies. + */ + +// Helper function to substitute process variables in a string +// Example: "Hello {name}" with {name: "World"} becomes "Hello World" +function substituteVariables(text, variables) { + if (!text || typeof text !== 'string') return text; + + return text.replace(/{([^{}]+)}/g, (match, variableName) => { + const variable = variables[variableName.trim()]; + return variable !== undefined ? variable : match; + }); +} + +// Helper function to substitute variables in a JSON object or string +function substituteVariablesInObject(obj, variables) { + if (typeof obj === 'string') { + try { + // If it's a JSON string, parse it, substitute, then stringify + const parsed = JSON.parse(obj); + return JSON.stringify(substituteVariablesInObject(parsed, variables)); + } catch (e) { + // If it's not valid JSON, treat as regular string + return substituteVariables(obj, variables); + } + } + + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + // Handle arrays + if (Array.isArray(obj)) { + return obj.map(item => substituteVariablesInObject(item, variables)); + } + + // Handle objects + const result = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = substituteVariablesInObject(value, variables); + } + + return result; +} + +// Execute an API call based on node configuration +export async function executeApiCall(nodeConfig, processVariables) { + const { + apiMethod = 'GET', + apiUrl = '', + requestBody = '', + headers = '{ "Content-Type": "application/json" }', + outputVariable = 'apiResponse', + errorVariable = 'apiError', + continueOnError = false + } = nodeConfig; + + // Combine global and process variables + const allVariables = { + ...processVariables.global || {}, + ...processVariables.process || {}, + ...processVariables + }; + + // Substitute variables in URL + const processedUrl = substituteVariables(apiUrl, allVariables); + + // Parse and process headers + let processedHeaders = {}; + try { + if (typeof headers === 'string') { + processedHeaders = JSON.parse(headers); + } else if (typeof headers === 'object' && headers !== null) { + processedHeaders = headers; + } + + // Substitute variables in headers + processedHeaders = substituteVariablesInObject(processedHeaders, allVariables); + } catch (error) { + console.error('Error processing headers:', error); + processedHeaders = { 'Content-Type': 'application/json' }; + } + + // Prepare request options + const options = { + method: apiMethod, + headers: processedHeaders + }; + + // Add request body for appropriate methods + if (['POST', 'PUT', 'PATCH'].includes(apiMethod) && requestBody) { + let processedBody; + + try { + // Try to parse as JSON if it's a string + if (typeof requestBody === 'string') { + const bodyObj = JSON.parse(requestBody); + processedBody = substituteVariablesInObject(bodyObj, allVariables); + options.body = JSON.stringify(processedBody); + } else { + // If it's already an object + processedBody = substituteVariablesInObject(requestBody, allVariables); + options.body = JSON.stringify(processedBody); + } + } catch (error) { + // If not valid JSON, treat as string with variable substitution + options.body = substituteVariables(requestBody, allVariables); + } + } + + // Create result object + const result = { + outputVariable, + errorVariable, + continueOnError, + outputScope: 'global', // Specify that output should go to global variables + success: false, + data: null, + error: null + }; + + try { + // Make the API call + const response = await fetch(processedUrl, options); + + // Get response data + let responseData; + const contentType = response.headers.get('content-type'); + + if (contentType && contentType.includes('application/json')) { + responseData = await response.json(); + } else { + responseData = await response.text(); + } + + // Store result data + result.success = response.ok; + result.data = { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries([...response.headers.entries()]), + data: responseData + }; + + // If not successful, also store as error + if (!response.ok) { + result.error = { + message: `API call failed with status ${response.status}`, + status: response.status, + statusText: response.statusText, + data: responseData + }; + } + } catch (error) { + // Handle network or other errors + result.success = false; + result.error = { + message: error.message || 'Unknown error occurred during API call', + stack: error.stack, + name: error.name + }; + } + + return result; +} + +export default { + executeApiCall +}; \ No newline at end of file diff --git a/stores/variableStore.js b/stores/variableStore.js index a13efa2..0d49096 100644 --- a/stores/variableStore.js +++ b/stores/variableStore.js @@ -37,23 +37,40 @@ export const useVariableStore = defineStore('variables', { actions: { // Add a new variable addVariable(variable) { - const { scope = 'process' } = variable; + // Set scope to global by default + const updatedVariable = { + ...variable, + scope: 'global' + }; - if (scope === 'global') { - this.globalVariables.push({ - ...variable, - scope: 'global' - }); + // For backward compatibility, still respect the scope if it's explicitly set + if (variable.scope === 'process') { + this.processVariables.push(updatedVariable); } else { - this.processVariables.push({ - ...variable, - scope: 'process' - }); + this.globalVariables.push(updatedVariable); + } + }, + + // Add a variable only if it doesn't already exist + addVariableIfNotExists(variable) { + const name = variable.name; + // Default to global scope + const scope = variable.scope || 'global'; + const variables = scope === 'global' + ? this.globalVariables + : this.processVariables; + + // Check if variable already exists + const exists = variables.some(v => v.name === name); + + // If it doesn't exist, add it + if (!exists) { + this.addVariable(variable); } }, // Update an existing variable - updateVariable(name, updates, scope = 'process') { + updateVariable(name, updates, scope = 'global') { const variables = scope === 'global' ? this.globalVariables : this.processVariables; @@ -62,13 +79,14 @@ export const useVariableStore = defineStore('variables', { if (index !== -1) { variables[index] = { ...variables[index], - ...updates + ...updates, + scope // Ensure the scope is preserved }; } }, // Delete a variable - deleteVariable(name, scope = 'process') { + deleteVariable(name, scope = 'global') { const variables = scope === 'global' ? this.globalVariables : this.processVariables;