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.
This commit is contained in:
parent
0b0b4a90b9
commit
dce3e7f0f2
410
components/process-flow/ApiNodeConfiguration.vue
Normal file
410
components/process-flow/ApiNodeConfiguration.vue
Normal file
@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div class="api-node-configuration">
|
||||
<h3 class="text-lg font-semibold mb-4">API Call Configuration</h3>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="nodeLabel" class="form-label">Node Label</label>
|
||||
<input
|
||||
id="nodeLabel"
|
||||
v-model="localNodeData.label"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="API Call"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="nodeDescription" class="form-label">Description</label>
|
||||
<textarea
|
||||
id="nodeDescription"
|
||||
v-model="localNodeData.description"
|
||||
class="form-control"
|
||||
placeholder="API call description"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="apiMethod" class="form-label">HTTP Method</label>
|
||||
<select
|
||||
id="apiMethod"
|
||||
v-model="localNodeData.apiMethod"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="apiUrl" class="form-label">API URL</label>
|
||||
<input
|
||||
id="apiUrl"
|
||||
v-model="localNodeData.apiUrl"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="https://example.com/api/endpoint"
|
||||
/>
|
||||
<small class="form-text text-muted">
|
||||
You can use process variables with curly braces: https://example.com/api/users/{userId}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4" v-if="showRequestBody">
|
||||
<label for="requestBody" class="form-label">Request Body</label>
|
||||
<textarea
|
||||
id="requestBody"
|
||||
v-model="localNodeData.requestBody"
|
||||
class="form-control font-mono"
|
||||
placeholder='{ "key": "value" }'
|
||||
rows="4"
|
||||
></textarea>
|
||||
<small class="form-text text-muted">
|
||||
You can use process variables with curly braces: { "userId": "{userId}" }
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="headers" class="form-label">Headers</label>
|
||||
<textarea
|
||||
id="headers"
|
||||
v-model="localNodeData.headers"
|
||||
class="form-control font-mono"
|
||||
placeholder='{ "Content-Type": "application/json" }'
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="outputVariable" class="form-label">Output Variable</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
id="outputVariable"
|
||||
v-model="localNodeData.outputVariable"
|
||||
class="form-control flex-grow"
|
||||
>
|
||||
<option value="" disabled>Select a global variable</option>
|
||||
<option value="apiResponse">Create new: apiResponse</option>
|
||||
<optgroup label="Global Variables">
|
||||
<option
|
||||
v-for="variable in availableVariables.global"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.description ? `${variable.name} (${variable.description})` : variable.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button
|
||||
@click="createGlobalVariable(localNodeData.outputVariable)"
|
||||
class="flex-shrink-0 px-3 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
|
||||
title="Create a new global variable"
|
||||
>
|
||||
<Icon name="material-symbols:add" />
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">
|
||||
API response will be stored in this global variable
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label class="form-label d-block">Error Handling</label>
|
||||
<div class="form-check">
|
||||
<input
|
||||
id="continueOnError"
|
||||
v-model="localNodeData.continueOnError"
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
/>
|
||||
<label for="continueOnError" class="form-check-label">
|
||||
Continue process execution on error
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="errorVariable" class="form-label">Error Variable</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
id="errorVariable"
|
||||
v-model="localNodeData.errorVariable"
|
||||
class="form-control flex-grow"
|
||||
>
|
||||
<option value="" disabled>Select a global variable</option>
|
||||
<option value="apiError">Create new: apiError</option>
|
||||
<optgroup label="Global Variables">
|
||||
<option
|
||||
v-for="variable in availableVariables.global"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.description ? `${variable.name} (${variable.description})` : variable.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button
|
||||
@click="createGlobalVariable(localNodeData.errorVariable, `API error from ${localNodeData.label}`)"
|
||||
class="flex-shrink-0 px-3 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
|
||||
title="Create a new global variable"
|
||||
>
|
||||
<Icon name="material-symbols:add" />
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">
|
||||
API errors will be stored in this global variable
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Test API Call Button -->
|
||||
<div class="form-group mt-6">
|
||||
<RsButton @click="testApiCall" variant="primary" :disabled="!localNodeData.apiUrl">
|
||||
<Icon name="material-symbols:send" class="mr-1" />
|
||||
Test API Call
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useProcessBuilderStore } from '@/stores/processBuilder';
|
||||
import { useVariableStore } from '@/stores/variableStore';
|
||||
|
||||
const props = defineProps({
|
||||
nodeId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// Get the stores
|
||||
const processStore = useProcessBuilderStore();
|
||||
const variableStore = useVariableStore();
|
||||
|
||||
// Local state for node data
|
||||
const localNodeData = ref({
|
||||
label: 'API Call',
|
||||
description: '',
|
||||
apiMethod: 'GET',
|
||||
apiUrl: '',
|
||||
requestBody: '',
|
||||
headers: '{ "Content-Type": "application/json" }',
|
||||
outputVariable: 'apiResponse',
|
||||
continueOnError: false,
|
||||
errorVariable: 'apiError'
|
||||
});
|
||||
|
||||
// Computed for showing request body based on method
|
||||
const showRequestBody = computed(() => {
|
||||
return ['POST', 'PUT', 'PATCH'].includes(localNodeData.value.apiMethod);
|
||||
});
|
||||
|
||||
// Get available variables for dropdowns
|
||||
const availableVariables = computed(() => {
|
||||
const globalVars = variableStore.getAllVariables.global.map(v => ({
|
||||
name: v.name || 'unnamed',
|
||||
label: v?.description
|
||||
? `${v.description} (${v.name || 'unnamed'}, global)`
|
||||
: `${v.name || 'unnamed'} (global)`,
|
||||
type: v.type || 'string',
|
||||
scope: 'global'
|
||||
}));
|
||||
|
||||
return {
|
||||
global: globalVars
|
||||
};
|
||||
});
|
||||
|
||||
// Load node data when component mounts or nodeId changes
|
||||
watch(() => props.nodeId, () => {
|
||||
loadNodeData();
|
||||
}, { immediate: true });
|
||||
|
||||
// Watch for changes in local data and emit updates
|
||||
watch(localNodeData, (newValue) => {
|
||||
saveChanges();
|
||||
}, { deep: true });
|
||||
|
||||
// Load node data from the store
|
||||
function loadNodeData() {
|
||||
const node = processStore.currentProcess.nodes.find(n => n.id === props.nodeId);
|
||||
if (node && node.data) {
|
||||
localNodeData.value = {
|
||||
label: node.label || 'API Call',
|
||||
description: node.data.description || '',
|
||||
apiMethod: node.data.apiMethod || 'GET',
|
||||
apiUrl: node.data.apiUrl || '',
|
||||
requestBody: node.data.requestBody || '',
|
||||
headers: node.data.headers || '{ "Content-Type": "application/json" }',
|
||||
outputVariable: node.data.outputVariable || 'apiResponse',
|
||||
continueOnError: node.data.continueOnError || false,
|
||||
errorVariable: node.data.errorVariable || 'apiError'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Function to create a new global variable
|
||||
function createGlobalVariable(name, description = '') {
|
||||
variableStore.addVariable({
|
||||
name,
|
||||
type: 'object',
|
||||
scope: 'global',
|
||||
value: null,
|
||||
description: description || `API response from ${localNodeData.value.label}`
|
||||
});
|
||||
}
|
||||
|
||||
// Save changes to the store
|
||||
function saveChanges() {
|
||||
const updates = {
|
||||
label: localNodeData.value.label,
|
||||
data: {
|
||||
...localNodeData.value,
|
||||
label: localNodeData.value.label // Ensure label is in both places
|
||||
}
|
||||
};
|
||||
|
||||
// Update the node in the store
|
||||
processStore.updateNode(props.nodeId, updates);
|
||||
|
||||
// Ensure variables exist
|
||||
if (localNodeData.value.outputVariable) {
|
||||
variableStore.addVariableIfNotExists({
|
||||
name: localNodeData.value.outputVariable,
|
||||
type: 'object',
|
||||
scope: 'global',
|
||||
value: null,
|
||||
description: `API response from ${localNodeData.value.label}`
|
||||
});
|
||||
}
|
||||
|
||||
if (localNodeData.value.errorVariable) {
|
||||
variableStore.addVariableIfNotExists({
|
||||
name: localNodeData.value.errorVariable,
|
||||
type: 'object',
|
||||
scope: 'global',
|
||||
value: null,
|
||||
description: `API error from ${localNodeData.value.label}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Test API call function
|
||||
async function testApiCall() {
|
||||
if (!localNodeData.value.apiUrl) return;
|
||||
|
||||
const isLoading = ref(true);
|
||||
const testResult = ref(null);
|
||||
|
||||
try {
|
||||
// Get process variables for substitution
|
||||
const processVariables = {
|
||||
global: {},
|
||||
process: {}
|
||||
};
|
||||
|
||||
// Extract variables from the store
|
||||
const allVars = variableStore.getAllVariables;
|
||||
allVars.process.forEach(v => {
|
||||
processVariables.process[v.name] = v.value;
|
||||
});
|
||||
allVars.global.forEach(v => {
|
||||
processVariables.global[v.name] = v.value;
|
||||
});
|
||||
|
||||
// Call the test API endpoint
|
||||
const response = await fetch('/api/process/test-api-node', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
nodeConfig: {
|
||||
apiMethod: localNodeData.value.apiMethod,
|
||||
apiUrl: localNodeData.value.apiUrl,
|
||||
requestBody: localNodeData.value.requestBody,
|
||||
headers: localNodeData.value.headers,
|
||||
outputVariable: localNodeData.value.outputVariable,
|
||||
errorVariable: localNodeData.value.errorVariable,
|
||||
continueOnError: localNodeData.value.continueOnError
|
||||
},
|
||||
processVariables
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
testResult.value = result;
|
||||
|
||||
if (result.success && localNodeData.value.outputVariable) {
|
||||
variableStore.updateVariable(
|
||||
localNodeData.value.outputVariable,
|
||||
{ value: result.data },
|
||||
'global'
|
||||
);
|
||||
} else if (!result.success && localNodeData.value.errorVariable) {
|
||||
variableStore.updateVariable(
|
||||
localNodeData.value.errorVariable,
|
||||
{ value: result.error },
|
||||
'global'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
testResult.value = {
|
||||
success: false,
|
||||
error: {
|
||||
message: error.message || 'An error occurred while testing the API call'
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-node-configuration {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-check-input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -56,14 +56,6 @@
|
||||
<span class="font-medium text-gray-900">{{
|
||||
variable.name
|
||||
}}</span>
|
||||
<RsBadge
|
||||
:variant="
|
||||
variable.scope === 'global' ? 'primary' : 'secondary'
|
||||
"
|
||||
size="sm"
|
||||
>
|
||||
{{ variable.scope }}
|
||||
</RsBadge>
|
||||
<RsBadge variant="outline" size="sm" class="text-gray-500">
|
||||
{{ variable.type }}
|
||||
</RsBadge>
|
||||
@ -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 @@
|
||||
}"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
name="scope"
|
||||
v-model="variableForm.scope"
|
||||
type="select"
|
||||
label="Scope"
|
||||
:options="[
|
||||
{ label: 'Process', value: 'process' },
|
||||
{ label: 'Global', value: 'global' },
|
||||
]"
|
||||
validation="required"
|
||||
:validation-messages="{
|
||||
required: 'Variable scope is required',
|
||||
}"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
name="description"
|
||||
v-model="variableForm.description"
|
||||
@ -166,14 +142,6 @@
|
||||
:rows="2"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
name="isRequired"
|
||||
v-model="variableForm.isRequired"
|
||||
type="checkbox"
|
||||
label="Required"
|
||||
help="Mark this variable as required"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end space-x-2 pt-4 border-t border-gray-200">
|
||||
<RsButton type="button" @click="closeModal" variant="tertiary">
|
||||
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
|
||||
|
@ -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) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- API Configuration for API Nodes -->
|
||||
<div v-if="selectedNodeData.type === 'api'">
|
||||
<ApiNodeConfiguration
|
||||
:nodeId="selectedNodeData.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Gateway Conditions -->
|
||||
<div v-if="selectedNodeData.type === 'gateway'">
|
||||
<GatewayConditionManager
|
||||
|
73
server/api/process/test-api-node.post.js
Normal file
73
server/api/process/test-api-node.post.js
Normal file
@ -0,0 +1,73 @@
|
||||
import { executeApiCall } from '../../../services/apiNodeService';
|
||||
|
||||
/**
|
||||
* Test API Node Endpoint
|
||||
*
|
||||
* This endpoint allows testing API node configurations without executing
|
||||
* the entire process. It takes the node configuration and process variables
|
||||
* as input and returns the API call result.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
173
services/apiNodeService.js
Normal file
173
services/apiNodeService.js
Normal file
@ -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
|
||||
};
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user