Merge pull request #10568 from Budibase/feature/sync-automations

Synchronous automation for App actions and webhook
This commit is contained in:
Peter Clement 2023-05-26 17:27:21 +01:00 committed by GitHub
commit 70dcbbd292
47 changed files with 643 additions and 105 deletions

View File

@ -90,6 +90,10 @@ export const useScimIntegration = () => {
return useFeature(Feature.SCIM)
}
export const useSyncAutomations = () => {
return useFeature(Feature.SYNC_AUTOMATIONS)
}
// QUOTAS
export const setAutomationLogsQuota = (value: number) => {

View File

@ -1,3 +1,4 @@
import { ActionStepID } from "constants/backend/automations"
import { TableNames } from "../constants"
import {
AUTO_COLUMN_DISPLAY_NAMES,
@ -53,3 +54,9 @@ export function buildAutoColumn(tableName, name, subtype) {
}
return base
}
export function checkForCollectStep(automation) {
return automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
}

View File

@ -6,23 +6,47 @@
Body,
Icon,
notifications,
Tags,
Tag,
} from "@budibase/bbui"
import { automationStore } from "builderStore"
import { admin } from "stores/portal"
import { automationStore, selectedAutomation } from "builderStore"
import { admin, licensing } from "stores/portal"
import { externalActions } from "./ExternalActions"
import { TriggerStepID } from "constants/backend/automations"
import { checkForCollectStep } from "builderStore/utils"
export let blockIdx
export let lastStep
const disabled = {
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
let selectedAction
let actionVal
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
$: collectBlockExists = checkForCollectStep($selectedAutomation)
const disabled = () => {
return {
SEND_EMAIL_SMTP: {
disabled: !$admin.checklist.smtp.checked,
message: "Please configure SMTP",
},
COLLECT: {
disabled: !lastStep || !syncAutomationsEnabled || collectBlockExists,
message: collectDisabledMessage(),
},
}
}
let selectedAction
let actionVal
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
const collectDisabledMessage = () => {
if (collectBlockExists) {
return "Only one Collect step allowed"
}
if (!lastStep) {
return "Only available as the last step"
}
}
const external = actions.reduce((acc, elm) => {
const [k, v] = elm
@ -38,6 +62,15 @@
acc[k] = v
}
delete acc.LOOP
// Filter out Collect block if not App Action or Webhook
if (
!collectBlockAllowedSteps.includes(
$selectedAutomation.definition.trigger.stepId
)
) {
delete acc.COLLECT
}
return acc
}, {})
@ -48,7 +81,6 @@
}
return acc
}, {})
console.log(plugins)
const selectAction = action => {
actionVal = action
@ -72,7 +104,7 @@
<ModalContent
title="Add automation step"
confirmText="Save"
size="M"
size="L"
disabled={!selectedAction}
onConfirm={addBlockToAutomation}
>
@ -107,7 +139,7 @@
<Detail size="S">Actions</Detail>
<div class="item-list">
{#each Object.entries(internal) as [idx, action]}
{@const isDisabled = disabled[idx] && disabled[idx].disabled}
{@const isDisabled = disabled()[idx] && disabled()[idx].disabled}
<div
class="item"
class:disabled={isDisabled}
@ -117,8 +149,14 @@
<div class="item-body">
<Icon name={action.icon} />
<Body size="XS">{action.name}</Body>
{#if isDisabled}
<Icon name="Help" tooltip={disabled[idx].message} />
{#if isDisabled && !syncAutomationsEnabled}
<div class="tag-color">
<Tags>
<Tag icon="LockClosed">Business</Tag>
</Tags>
</div>
{:else if isDisabled}
<Icon name="Help" tooltip={disabled()[idx].message} />
{/if}
</div>
</div>
@ -152,6 +190,7 @@
display: flex;
margin-left: var(--spacing-m);
gap: var(--spacing-m);
align-items: center;
}
.item-list {
display: grid;
@ -181,4 +220,8 @@
.disabled :global(.spectrum-Body) {
color: var(--spectrum-global-color-gray-600);
}
.tag-color :global(.spectrum-Tags-item) {
background: var(--spectrum-global-color-gray-200);
}
</style>

View File

@ -17,7 +17,11 @@
import ActionModal from "./ActionModal.svelte"
import FlowItemHeader from "./FlowItemHeader.svelte"
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
import { ActionStepID, TriggerStepID } from "constants/backend/automations"
import {
ActionStepID,
TriggerStepID,
Features,
} from "constants/backend/automations"
import { permissions } from "stores/backend"
export let block
@ -31,6 +35,9 @@
let showLooping = false
let role
$: collectBlockExists = $selectedAutomation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
$: automationId = $selectedAutomation?._id
$: showBindingPicker =
block.stepId === ActionStepID.CREATE_ROW ||
@ -184,7 +191,7 @@
{#if !isTrigger}
<div>
<div class="block-options">
{#if !loopBlock}
{#if block?.features?.[Features.LOOPING] || !block.features}
<ActionButton on:click={() => addLooping()} icon="Reuse">
Add Looping
</ActionButton>
@ -224,21 +231,28 @@
</Layout>
</div>
{/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
</div>
<div class="separator" />
<Icon on:click={() => actionModal.show()} hoverable name="AddCircle" size="S" />
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
{#if !collectBlockExists || !lastStep}
<div class="separator" />
<Icon
on:click={() => actionModal.show()}
hoverable
name="AddCircle"
size="S"
/>
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
<div class="separator" />
{/if}
{/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal {lastStep} {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
<style>
.delete-padding {
padding-left: 30px;

View File

@ -126,8 +126,7 @@
}
const getAllBindings = (bindings, eventContextBindings, actions) => {
let allBindings = eventContextBindings.concat(bindings)
let allBindings = []
if (!actions) {
return []
}
@ -145,14 +144,35 @@
.forEach(action => {
// Check we have a binding for this action, and generate one if not
const stateBinding = makeStateBinding(action.parameters.key)
const hasKey = allBindings.some(binding => {
const hasKey = bindings.some(binding => {
return binding.runtimeBinding === stateBinding.runtimeBinding
})
if (!hasKey) {
allBindings.push(stateBinding)
bindings.push(stateBinding)
}
})
// Get which indexes are asynchronous automations as we want to filter them out from the bindings
const asynchronousAutomationIndexes = actions
.map((action, index) => {
if (
action[EVENT_TYPE_KEY] === "Trigger Automation" &&
!action.parameters?.synchronous
) {
return index
}
})
.filter(index => index !== undefined)
// Based on the above, filter out the asynchronous automations from the bindings
if (asynchronousAutomationIndexes) {
allBindings = eventContextBindings
.filter((binding, index) => {
return !asynchronousAutomationIndexes.includes(index)
})
.concat(bindings)
} else {
allBindings = eventContextBindings.concat(bindings)
}
return allBindings
}
</script>

View File

@ -1,8 +1,8 @@
<script>
import { Select, Label, Input, Checkbox } from "@budibase/bbui"
import { Select, Label, Input, Checkbox, Icon } from "@budibase/bbui"
import { automationStore } from "builderStore"
import SaveFields from "./SaveFields.svelte"
import { TriggerStepID } from "constants/backend/automations"
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
export let parameters = {}
export let bindings = []
@ -16,6 +16,14 @@
? AUTOMATION_STATUS.EXISTING
: AUTOMATION_STATUS.NEW
$: {
if (automationStatus === AUTOMATION_STATUS.NEW) {
parameters.synchronous = false
}
parameters.synchronous = automations.find(
automation => automation._id === parameters.automationId
)?.synchronous
}
$: automations = $automationStore.automations
.filter(a => a.definition.trigger?.stepId === TriggerStepID.APP)
.map(automation => {
@ -23,10 +31,15 @@
automation.definition.trigger.inputs.fields || {}
).map(([name, type]) => ({ name, type }))
let hasCollectBlock = automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
return {
name: automation.name,
_id: automation._id,
schema,
synchronous: hasCollectBlock,
}
})
$: hasAutomations = automations && automations.length > 0
@ -35,6 +48,8 @@
)
$: selectedSchema = selectedAutomation?.schema
$: error = parameters.timeout > 120 ? "Timeout must be less than 120s" : null
const onFieldsChanged = e => {
parameters.fields = Object.entries(e.detail || {}).reduce(
(acc, [key, value]) => {
@ -57,6 +72,14 @@
parameters.fields = {}
parameters.automationId = automations[0]?._id
}
const onChange = value => {
let automationId = value.detail
parameters.synchronous = automations.find(
automation => automation._id === automationId
)?.synchronous
parameters.automationId = automationId
}
</script>
<div class="root">
@ -85,6 +108,7 @@
{#if automationStatus === AUTOMATION_STATUS.EXISTING}
<Select
on:change={onChange}
bind:value={parameters.automationId}
placeholder="Choose automation"
options={automations}
@ -98,6 +122,29 @@
/>
{/if}
{#if parameters.synchronous}
<Label small />
<div class="synchronous-info">
<Icon name="Info" />
<div>
<i
>This automation will run synchronously as it contains a Collect
step</i
>
</div>
</div>
<Label small />
<div class="timeout-width">
<Input
label="Timeout in seconds (120 max)"
type="number"
{error}
bind:value={parameters.timeout}
/>
</div>
{/if}
<Label small />
<Checkbox
text="Do not display default notification"
@ -133,6 +180,9 @@
max-width: 800px;
margin: 0 auto;
}
.timeout-width {
width: 30%;
}
.params {
display: grid;
@ -142,6 +192,11 @@
align-items: center;
}
.synchronous-info {
display: flex;
gap: var(--spacing-s);
}
.fields {
margin-top: var(--spacing-l);
display: grid;

View File

@ -57,7 +57,13 @@
{
"name": "Trigger Automation",
"type": "application",
"component": "TriggerAutomation"
"component": "TriggerAutomation",
"context": [
{
"label": "Automation Result",
"value": "result"
}
]
},
{
"name": "Update Field Value",

View File

@ -20,9 +20,14 @@ export const ActionStepID = {
FILTER: "FILTER",
QUERY_ROWS: "QUERY_ROWS",
LOOP: "LOOP",
COLLECT: "COLLECT",
// these used to be lowercase step IDs, maintain for backwards compat
discord: "discord",
slack: "slack",
zapier: "zapier",
integromat: "integromat",
}
export const Features = {
LOOPING: "LOOPING",
}

View File

@ -116,6 +116,9 @@ export const createLicensingStore = () => {
const auditLogsEnabled = license.features.includes(
Constants.Features.AUDIT_LOGS
)
const syncAutomationsEnabled = license.features.includes(
Constants.Features.SYNC_AUTOMATIONS
)
store.update(state => {
return {
...state,
@ -130,6 +133,7 @@ export const createLicensingStore = () => {
environmentVariablesEnabled,
auditLogsEnabled,
enforceableSSO,
syncAutomationsEnabled,
}
})
},

View File

@ -122,13 +122,23 @@ const deleteRowHandler = async action => {
}
const triggerAutomationHandler = async action => {
const { fields, notificationOverride } = action.parameters
const { fields, notificationOverride, timeout } = action.parameters
if (fields) {
try {
await API.triggerAutomation({
const result = await API.triggerAutomation({
automationId: action.parameters.automationId,
fields,
timeout,
})
// Value will exist if automation is synchronous, so return it.
if (result.value) {
if (!notificationOverride) {
notificationStore.actions.success("Automation ran successfully")
}
return { result }
}
if (!notificationOverride) {
notificationStore.actions.success("Automation triggered")
}
@ -138,7 +148,6 @@ const triggerAutomationHandler = async action => {
}
}
}
const navigationHandler = action => {
const { url, peek, externalNewTab } = action.parameters
routeStore.actions.navigate(url, peek, externalNewTab)

View File

@ -4,10 +4,10 @@ export const buildAutomationEndpoints = API => ({
* @param automationId the ID of the automation to trigger
* @param fields the fields to trigger the automation with
*/
triggerAutomation: async ({ automationId, fields }) => {
triggerAutomation: async ({ automationId, fields, timeout }) => {
return await API.post({
url: `/api/automations/${automationId}/trigger`,
body: { fields },
body: { fields, timeout },
})
},

View File

@ -70,6 +70,7 @@ export const Features = {
ENFORCEABLE_SSO: "enforceableSSO",
BRANDING: "branding",
SCIM: "scim",
SYNC_AUTOMATIONS: "syncAutomations",
}
// Role IDs

@ -1 +1 @@
Subproject commit 2d6f999586fcb62bc98b26416ee406f6328e6615
Subproject commit 3d307df17a53ba25bbf5d9ddc94b1706c813eb6f

View File

@ -14,9 +14,16 @@ import { deleteEntityMetadata } from "../../utilities"
import { MetadataTypes } from "../../constants"
import { setTestFlag, clearTestFlag } from "../../utilities/redis"
import { context, cache, events } from "@budibase/backend-core"
import { automations } from "@budibase/pro"
import { Automation, BBContext } from "@budibase/types"
import { automations, features } from "@budibase/pro"
import {
Automation,
AutomationActionStepId,
AutomationResults,
BBContext,
} from "@budibase/types"
import { getActionDefinitions as actionDefs } from "../../automations/actions"
import sdk from "../../sdk"
import { db as dbCore } from "@budibase/backend-core"
async function getActionDefinitions() {
return removeDeprecated(await actionDefs())
@ -257,6 +264,26 @@ export async function getDefinitionList(ctx: BBContext) {
export async function trigger(ctx: BBContext) {
const db = context.getAppDB()
let automation = await db.get(ctx.params.id)
let hasCollectStep = sdk.automations.utils.checkForCollectStep(automation)
if (hasCollectStep && (await features.isSyncAutomationsEnabled())) {
const response: AutomationResults = await triggers.externalTrigger(
automation,
{
fields: ctx.request.body.fields,
timeout: ctx.request.body.timeout * 1000 || 120000,
},
{ getResponses: true }
)
let collectedValue = response.steps.find(
step => step.stepId === AutomationActionStepId.COLLECT
)
ctx.body = collectedValue?.outputs
} else {
if (ctx.appId && !dbCore.isProdAppID(ctx.appId)) {
ctx.throw(400, "Only apps in production support this endpoint")
}
await triggers.externalTrigger(automation, {
...ctx.request.body,
appId: ctx.appId,
@ -265,6 +292,7 @@ export async function trigger(ctx: BBContext) {
message: `Automation ${automation._id} has been triggered.`,
automation,
}
}
}
function prepareTestInput(input: any) {

View File

@ -6,8 +6,11 @@ import {
WebhookActionType,
BBContext,
Automation,
AutomationActionStepId,
} from "@budibase/types"
import sdk from "../../sdk"
import * as pro from "@budibase/pro"
const toJsonSchema = require("to-json-schema")
const validate = require("jsonschema").validate
@ -78,16 +81,37 @@ export async function trigger(ctx: BBContext) {
if (webhook.action.type === WebhookActionType.AUTOMATION) {
// trigger with both the pure request and then expand it
// incase the user has produced a schema to bind to
let hasCollectStep = sdk.automations.utils.checkForCollectStep(target)
if (hasCollectStep && (await pro.features.isSyncAutomationsEnabled())) {
const response = await triggers.externalTrigger(
target,
{
body: ctx.request.body,
...ctx.request.body,
appId: prodAppId,
},
{ getResponses: true }
)
let collectedValue = response.steps.find(
(step: any) => step.stepId === AutomationActionStepId.COLLECT
)
ctx.status = 200
ctx.body = collectedValue.outputs
} else {
await triggers.externalTrigger(target, {
body: ctx.request.body,
...ctx.request.body,
appId: prodAppId,
})
}
ctx.status = 200
ctx.body = {
message: "Webhook trigger fired successfully",
}
}
}
} catch (err: any) {
if (err.status === 404) {
ctx.status = 200

View File

@ -65,7 +65,6 @@ router
)
.post(
"/api/automations/:id/trigger",
appInfoMiddleware({ appType: AppType.PROD }),
paramResource("id"),
authorized(
permissions.PermissionType.AUTOMATION,

View File

@ -1,15 +1,27 @@
const {
import {
checkBuilderEndpoint,
getAllTableRows,
clearAllAutomations,
testAutomation,
} = require("./utilities/TestFunctions")
const setup = require("./utilities")
const { basicAutomation, newAutomation, automationTrigger, automationStep } = setup.structures
const MAX_RETRIES = 4
const { TRIGGER_DEFINITIONS, BUILTIN_ACTION_DEFINITIONS } = require("../../../automations")
const { events } = require("@budibase/backend-core")
} from "./utilities/TestFunctions"
import * as setup from "./utilities"
import {
TRIGGER_DEFINITIONS,
BUILTIN_ACTION_DEFINITIONS,
} from "../../../automations"
import { events } from "@budibase/backend-core"
import sdk from "../../../sdk"
import { Automation } from "@budibase/types"
import { mocks } from "@budibase/backend-core/tests"
const MAX_RETRIES = 4
let {
basicAutomation,
newAutomation,
automationTrigger,
automationStep,
collectAutomation,
} = setup.structures
jest.setTimeout(30000)
@ -24,6 +36,7 @@ describe("/automations", () => {
})
beforeEach(() => {
// @ts-ignore
events.automation.deleted.mockClear()
})
@ -32,7 +45,7 @@ describe("/automations", () => {
const res = await request
.get(`/api/automations/action/list`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(Object.keys(res.body).length).not.toEqual(0)
@ -42,7 +55,7 @@ describe("/automations", () => {
const res = await request
.get(`/api/automations/trigger/list`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(Object.keys(res.body).length).not.toEqual(0)
@ -52,14 +65,18 @@ describe("/automations", () => {
const res = await request
.get(`/api/automations/definitions/list`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
let definitionsLength = Object.keys(BUILTIN_ACTION_DEFINITIONS).length
definitionsLength-- // OUTGOING_WEBHOOK is deprecated
expect(Object.keys(res.body.action).length).toBeGreaterThanOrEqual(definitionsLength)
expect(Object.keys(res.body.trigger).length).toEqual(Object.keys(TRIGGER_DEFINITIONS).length)
expect(Object.keys(res.body.action).length).toBeGreaterThanOrEqual(
definitionsLength
)
expect(Object.keys(res.body.trigger).length).toEqual(
Object.keys(TRIGGER_DEFINITIONS).length
)
})
})
@ -72,7 +89,7 @@ describe("/automations", () => {
.post(`/api/automations`)
.set(config.defaultHeaders())
.send(automation)
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual("Automation created successfully")
@ -91,7 +108,7 @@ describe("/automations", () => {
.post(`/api/automations`)
.set(config.defaultHeaders())
.send(automation)
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual("Automation created successfully")
@ -107,7 +124,7 @@ describe("/automations", () => {
config,
method: "POST",
url: `/api/automations`,
body: automation
body: automation,
})
})
})
@ -118,7 +135,7 @@ describe("/automations", () => {
const res = await request
.get(`/api/automations/${automation._id}`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._id).toEqual(automation._id)
expect(res.body._rev).toEqual(automation._rev)
@ -134,8 +151,8 @@ describe("/automations", () => {
row: {
name: "{{trigger.row.name}}",
description: "{{trigger.row.description}}",
tableId: table._id
}
tableId: table._id,
},
}
automation.appId = config.appId
automation = await config.createAutomation(automation)
@ -162,23 +179,68 @@ describe("/automations", () => {
})
})
describe("update", () => {
describe("trigger", () => {
it("does not trigger an automation when not synchronous and in dev", async () => {
let automation = newAutomation()
automation = await config.createAutomation(automation)
const res = await request
.post(`/api/automations/${automation._id}/trigger`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
const update = async (automation) => {
expect(res.body.message).toEqual(
"Only apps in production support this endpoint"
)
})
it("triggers a synchronous automation", async () => {
mocks.licenses.useSyncAutomations()
let automation = collectAutomation()
automation = await config.createAutomation(automation)
const res = await request
.post(`/api/automations/${automation._id}/trigger`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.success).toEqual(true)
expect(res.body.value).toEqual([1, 2, 3])
})
it("triggers an asynchronous automation", async () => {
let automation = newAutomation()
automation = await config.createAutomation(automation)
await config.publish()
const res = await request
.post(`/api/automations/${automation._id}/trigger`)
.set(config.defaultHeaders({}, true))
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual(
`Automation ${automation._id} has been triggered.`
)
})
})
describe("update", () => {
const update = async (automation: Automation) => {
return request
.put(`/api/automations`)
.set(config.defaultHeaders())
.send(automation)
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
}
const updateWithPost = async (automation) => {
const updateWithPost = async (automation: Automation) => {
return request
.post(`/api/automations`)
.set(config.defaultHeaders())
.send(automation)
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
}
@ -199,7 +261,9 @@ describe("/automations", () => {
expect(automationRes._rev).not.toEqual(automation._rev)
// content updates
expect(automationRes.name).toEqual("Updated Name")
expect(message).toEqual(`Automation ${automation._id} updated successfully.`)
expect(message).toEqual(
`Automation ${automation._id} updated successfully.`
)
// events
expect(events.automation.created).not.toBeCalled()
expect(events.automation.stepCreated).not.toBeCalled()
@ -207,7 +271,6 @@ describe("/automations", () => {
expect(events.automation.triggerUpdated).not.toBeCalled()
})
it("updates a automations name using POST request", async () => {
let automation = newAutomation()
await config.createAutomation(automation)
@ -226,7 +289,9 @@ describe("/automations", () => {
expect(automationRes._rev).not.toEqual(automation._rev)
// content updates
expect(automationRes.name).toEqual("Updated Name")
expect(message).toEqual(`Automation ${automation._id} updated successfully.`)
expect(message).toEqual(
`Automation ${automation._id} updated successfully.`
)
// events
expect(events.automation.created).not.toBeCalled()
expect(events.automation.stepCreated).not.toBeCalled()
@ -237,7 +302,9 @@ describe("/automations", () => {
it("updates an automation trigger", async () => {
let automation = newAutomation()
automation = await config.createAutomation(automation)
automation.definition.trigger = automationTrigger(TRIGGER_DEFINITIONS.WEBHOOK)
automation.definition.trigger = automationTrigger(
TRIGGER_DEFINITIONS.WEBHOOK
)
jest.clearAllMocks()
await update(automation)
@ -266,7 +333,6 @@ describe("/automations", () => {
expect(events.automation.triggerUpdated).not.toBeCalled()
})
it("removes automation steps", async () => {
let automation = newAutomation()
automation.definition.steps.push(automationStep())
@ -305,11 +371,11 @@ describe("/automations", () => {
it("return all the automations for an instance", async () => {
await clearAllAutomations(config)
const autoConfig = basicAutomation()
automation = await config.createAutomation(autoConfig)
await config.createAutomation(autoConfig)
const res = await request
.get(`/api/automations`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body[0]).toEqual(expect.objectContaining(autoConfig))
@ -330,7 +396,7 @@ describe("/automations", () => {
const res = await request
.delete(`/api/automations/${automation.id}/${automation.rev}`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.id).toEqual(automation._id)
@ -346,4 +412,13 @@ describe("/automations", () => {
})
})
})
describe("checkForCollectStep", () => {
it("should return true if a collect step exists in an automation", async () => {
let automation = collectAutomation()
await config.createAutomation(automation)
let res = await sdk.automations.utils.checkForCollectStep(automation)
expect(res).toEqual(true)
})
})
})

View File

@ -1,11 +1,13 @@
const setup = require("./utilities")
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const { basicWebhook, basicAutomation } = setup.structures
import { Webhook } from "@budibase/types"
import * as setup from "./utilities"
import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import { mocks } from "@budibase/backend-core/tests"
const { basicWebhook, basicAutomation, collectAutomation } = setup.structures
describe("/webhooks", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let webhook
let webhook: Webhook
afterAll(setup.afterAll)
@ -13,10 +15,11 @@ describe("/webhooks", () => {
config.modeSelf()
await config.init()
const autoConfig = basicAutomation()
autoConfig.definition.trigger = {
schema: { outputs: { properties: {} } },
inputs: {},
autoConfig.definition.trigger.schema = {
outputs: { properties: {} },
inputs: { properties: {} },
}
autoConfig.definition.trigger.inputs = {}
await config.createAutomation(autoConfig)
webhook = await config.createWebhook()
}
@ -97,7 +100,7 @@ describe("/webhooks", () => {
const res = await request
.post(`/api/webhooks/schema/${config.getAppId()}/${webhook._id}`)
.send({
a: 1
a: 1,
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
@ -112,7 +115,7 @@ describe("/webhooks", () => {
expect(fetch.body[0]).toBeDefined()
expect(fetch.body[0].bodySchema).toEqual({
properties: {
a: { type: "integer" }
a: { type: "integer" },
},
type: "object",
})
@ -131,4 +134,23 @@ describe("/webhooks", () => {
expect(res.body.message).toBeDefined()
})
})
it("should trigger a synchronous webhook call ", async () => {
mocks.licenses.useSyncAutomations()
let automation = collectAutomation()
let newAutomation = await config.createAutomation(automation)
let syncWebhook = await config.createWebhook(
basicWebhook(newAutomation._id)
)
// replicate changes before checking webhook
await config.publish()
const res = await request
.post(`/api/webhooks/trigger/${config.prodAppId}/${syncWebhook._id}`)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.success).toEqual(true)
expect(res.body.value).toEqual([1, 2, 3])
})
})

View File

@ -14,6 +14,7 @@ import * as filter from "./steps/filter"
import * as delay from "./steps/delay"
import * as queryRow from "./steps/queryRows"
import * as loop from "./steps/loop"
import * as collect from "./steps/collect"
import env from "../environment"
import {
AutomationStepSchema,
@ -39,6 +40,7 @@ const ACTION_IMPLS: Record<
DELAY: delay.run,
FILTER: filter.run,
QUERY_ROWS: queryRow.run,
COLLECT: collect.run,
// these used to be lowercase step IDs, maintain for backwards compat
discord: discord.run,
slack: slack.run,
@ -59,6 +61,7 @@ export const BUILTIN_ACTION_DEFINITIONS: Record<string, AutomationStepSchema> =
FILTER: filter.definition,
QUERY_ROWS: queryRow.definition,
LOOP: loop.definition,
COLLECT: collect.definition,
// these used to be lowercase step IDs, maintain for backwards compat
discord: discord.definition,
slack: slack.definition,

View File

@ -5,6 +5,7 @@ import environment from "../../environment"
import {
AutomationActionStepId,
AutomationCustomIOType,
AutomationFeature,
AutomationIOType,
AutomationStepInput,
AutomationStepSchema,
@ -18,6 +19,9 @@ export const definition: AutomationStepSchema = {
description: "Run a bash script",
type: AutomationStepType.ACTION,
internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.EXECUTE_BASH,
inputs: {},
schema: {

View File

@ -0,0 +1,58 @@
import {
AutomationActionStepId,
AutomationStepSchema,
AutomationStepInput,
AutomationStepType,
AutomationIOType,
AutomationFeature,
} from "@budibase/types"
export const definition: AutomationStepSchema = {
name: "Collect Data",
tagline: "Collect data to be sent to design",
icon: "Collection",
description:
"Collects specified data so it can be provided to the design section",
type: AutomationStepType.ACTION,
internal: true,
features: {},
stepId: AutomationActionStepId.COLLECT,
inputs: {},
schema: {
inputs: {
properties: {
collection: {
type: AutomationIOType.STRING,
title: "What to Collect",
},
},
required: ["collection"],
},
outputs: {
properties: {
success: {
type: AutomationIOType.BOOLEAN,
description: "Whether the action was successful",
},
value: {
type: AutomationIOType.STRING,
description: "Collected data",
},
},
required: ["success", "value"],
},
},
}
export async function run({ inputs }: AutomationStepInput) {
if (!inputs.collection) {
return {
success: false,
}
} else {
return {
success: true,
value: inputs.collection,
}
}
}

View File

@ -4,6 +4,7 @@ import { buildCtx } from "./utils"
import {
AutomationActionStepId,
AutomationCustomIOType,
AutomationFeature,
AutomationIOType,
AutomationStepInput,
AutomationStepSchema,
@ -17,6 +18,9 @@ export const definition: AutomationStepSchema = {
description: "Add a row to your database",
type: AutomationStepType.ACTION,
internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.CREATE_ROW,
inputs: {},
schema: {

View File

@ -14,6 +14,7 @@ export const definition: AutomationStepSchema = {
description: "Delay the automation until an amount of time has passed",
stepId: AutomationActionStepId.DELAY,
internal: true,
features: {},
inputs: {},
schema: {
inputs: {

View File

@ -8,6 +8,7 @@ import {
AutomationStepType,
AutomationIOType,
AutomationCustomIOType,
AutomationFeature,
} from "@budibase/types"
export const definition: AutomationStepSchema = {
@ -18,6 +19,9 @@ export const definition: AutomationStepSchema = {
type: AutomationStepType.ACTION,
stepId: AutomationActionStepId.DELETE_ROW,
internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {},
schema: {
inputs: {

View File

@ -6,6 +6,7 @@ import {
AutomationStepInput,
AutomationStepType,
AutomationIOType,
AutomationFeature,
} from "@budibase/types"
const DEFAULT_USERNAME = "Budibase Automate"
@ -19,6 +20,9 @@ export const definition: AutomationStepSchema = {
stepId: AutomationActionStepId.discord,
type: AutomationStepType.ACTION,
internal: false,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {},
schema: {
inputs: {

View File

@ -4,6 +4,7 @@ import * as automationUtils from "../automationUtils"
import {
AutomationActionStepId,
AutomationCustomIOType,
AutomationFeature,
AutomationIOType,
AutomationStepInput,
AutomationStepSchema,
@ -18,6 +19,9 @@ export const definition: AutomationStepSchema = {
type: AutomationStepType.ACTION,
stepId: AutomationActionStepId.EXECUTE_QUERY,
internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {},
schema: {
inputs: {

View File

@ -4,6 +4,7 @@ import * as automationUtils from "../automationUtils"
import {
AutomationActionStepId,
AutomationCustomIOType,
AutomationFeature,
AutomationIOType,
AutomationStepInput,
AutomationStepSchema,
@ -19,6 +20,9 @@ export const definition: AutomationStepSchema = {
internal: true,
stepId: AutomationActionStepId.EXECUTE_SCRIPT,
inputs: {},
features: {
[AutomationFeature.LOOPING]: true,
},
schema: {
inputs: {
properties: {

View File

@ -28,6 +28,7 @@ export const definition: AutomationStepSchema = {
"Conditionally halt automations which do not meet certain conditions",
type: AutomationStepType.LOGIC,
internal: true,
features: {},
stepId: AutomationActionStepId.FILTER,
inputs: {
condition: FilterConditions.EQUAL,

View File

@ -13,6 +13,7 @@ export const definition: AutomationStepSchema = {
description: "Loop",
stepId: AutomationActionStepId.LOOP,
internal: true,
features: {},
inputs: {},
schema: {
inputs: {

View File

@ -6,6 +6,7 @@ import {
AutomationStepInput,
AutomationStepType,
AutomationIOType,
AutomationFeature,
} from "@budibase/types"
export const definition: AutomationStepSchema = {
@ -18,6 +19,9 @@ export const definition: AutomationStepSchema = {
stepId: AutomationActionStepId.integromat,
type: AutomationStepType.ACTION,
internal: false,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {},
schema: {
inputs: {

View File

@ -22,6 +22,7 @@ export const definition: AutomationStepSchema = {
description: "Interact with the OpenAI ChatGPT API.",
type: AutomationStepType.ACTION,
internal: true,
features: {},
stepId: AutomationActionStepId.OPENAI,
inputs: {
prompt: "",

View File

@ -4,6 +4,7 @@ import * as automationUtils from "../automationUtils"
import {
AutomationActionStepId,
AutomationCustomIOType,
AutomationFeature,
AutomationIOType,
AutomationStepInput,
AutomationStepSchema,
@ -32,6 +33,9 @@ export const definition: AutomationStepSchema = {
description: "Send a request of specified method to a URL",
type: AutomationStepType.ACTION,
internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.OUTGOING_WEBHOOK,
inputs: {
requestMethod: "POST",

View File

@ -6,6 +6,7 @@ import * as automationUtils from "../automationUtils"
import {
AutomationActionStepId,
AutomationCustomIOType,
AutomationFeature,
AutomationIOType,
AutomationStepInput,
AutomationStepSchema,
@ -42,6 +43,9 @@ export const definition: AutomationStepSchema = {
type: AutomationStepType.ACTION,
stepId: AutomationActionStepId.QUERY_ROWS,
internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {},
schema: {
inputs: {

View File

@ -6,6 +6,7 @@ import {
AutomationStepInput,
AutomationStepType,
AutomationIOType,
AutomationFeature,
} from "@budibase/types"
export const definition: AutomationStepSchema = {
@ -15,6 +16,9 @@ export const definition: AutomationStepSchema = {
name: "Send Email (SMTP)",
type: AutomationStepType.ACTION,
internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.SEND_EMAIL_SMTP,
inputs: {},
schema: {

View File

@ -4,6 +4,7 @@ import {
AutomationStepInput,
AutomationStepType,
AutomationIOType,
AutomationFeature,
} from "@budibase/types"
/**
@ -19,6 +20,9 @@ export const definition: AutomationStepSchema = {
description: "Logs the given text to the server (using console.log)",
type: AutomationStepType.ACTION,
internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.SERVER_LOG,
inputs: {
text: "",

View File

@ -6,6 +6,7 @@ import {
AutomationStepInput,
AutomationStepType,
AutomationIOType,
AutomationFeature,
} from "@budibase/types"
export const definition: AutomationStepSchema = {
@ -16,6 +17,9 @@ export const definition: AutomationStepSchema = {
stepId: AutomationActionStepId.slack,
type: AutomationStepType.ACTION,
internal: false,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {},
schema: {
inputs: {

View File

@ -4,6 +4,7 @@ import { buildCtx } from "./utils"
import {
AutomationActionStepId,
AutomationCustomIOType,
AutomationFeature,
AutomationIOType,
AutomationStepInput,
AutomationStepSchema,
@ -17,6 +18,9 @@ export const definition: AutomationStepSchema = {
description: "Update a row in your database",
type: AutomationStepType.ACTION,
internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.UPDATE_ROW,
inputs: {},
schema: {

View File

@ -6,6 +6,7 @@ import {
AutomationStepInput,
AutomationStepType,
AutomationIOType,
AutomationFeature,
} from "@budibase/types"
export const definition: AutomationStepSchema = {
@ -13,6 +14,9 @@ export const definition: AutomationStepSchema = {
stepId: AutomationActionStepId.zapier,
type: AutomationStepType.ACTION,
internal: false,
features: {
[AutomationFeature.LOOPING]: true,
},
description: "Trigger a Zapier Zap via webhooks",
tagline: "Trigger a Zapier Zap",
icon: "ri-flashlight-line",

View File

@ -10,6 +10,7 @@ import * as utils from "./utils"
import env from "../environment"
import { context, db as dbCore } from "@budibase/backend-core"
import { Automation, Row, AutomationData, AutomationJob } from "@budibase/types"
import { executeSynchronously } from "../threads/automation"
export const TRIGGER_DEFINITIONS = definitions
const JOB_OPTS = {
@ -91,7 +92,7 @@ emitter.on("row:delete", async function (event) {
export async function externalTrigger(
automation: Automation,
params: { fields: Record<string, any> },
params: { fields: Record<string, any>; timeout?: number },
{ getResponses }: { getResponses?: boolean } = {}
) {
if (
@ -118,7 +119,7 @@ export async function externalTrigger(
automation,
}
const job = { data } as AutomationJob
return utils.processEvent(job)
return executeSynchronously(job)
} else {
return automationQueue.add(data, JOB_OPTS)
}

View File

@ -1,5 +1,7 @@
import * as webhook from "./webhook"
import * as utils from "./utils"
export default {
webhook,
utils,
}

View File

@ -0,0 +1,7 @@
import { Automation, AutomationActionStepId } from "@budibase/types"
export function checkForCollectStep(automation: Automation) {
return automation.definition.steps.some(
(step: any) => step.stepId === AutomationActionStepId.COLLECT
)
}

View File

@ -373,7 +373,7 @@ class TestConfiguration {
// HEADERS
defaultHeaders(extras = {}) {
defaultHeaders(extras = {}, prodApp = false) {
const tenantId = this.getTenantId()
const authObj: AuthToken = {
userId: this.defaultUserValues.globalUserId,
@ -390,7 +390,9 @@ class TestConfiguration {
...extras,
}
if (this.appId) {
if (prodApp) {
headers[constants.Header.APP_ID] = this.prodAppId
} else if (this.appId) {
headers[constants.Header.APP_ID] = this.appId
}
return headers

View File

@ -199,6 +199,48 @@ export function loopAutomation(tableId: string, loopOpts?: any): Automation {
return automation as Automation
}
export function collectAutomation(tableId?: string): Automation {
const automation: any = {
name: "looping",
type: "automation",
definition: {
steps: [
{
id: "b",
type: "ACTION",
internal: true,
stepId: AutomationActionStepId.EXECUTE_SCRIPT,
inputs: {
code: "return [1,2,3]",
},
schema: BUILTIN_ACTION_DEFINITIONS.EXECUTE_SCRIPT.schema,
},
{
id: "c",
type: "ACTION",
internal: true,
stepId: AutomationActionStepId.COLLECT,
inputs: {
collection: "{{ literal steps.1.value }}",
},
schema: BUILTIN_ACTION_DEFINITIONS.SERVER_LOG.schema,
},
],
trigger: {
id: "a",
type: "TRIGGER",
event: "row:save",
stepId: AutomationTriggerStepId.ROW_SAVED,
inputs: {
tableId,
},
schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema,
},
},
}
return automation as Automation
}
export function basicRow(tableId: string) {
return {
name: "Test Contact",

View File

@ -68,6 +68,7 @@ class Orchestrator {
constructor(job: AutomationJob) {
let automation = job.data.automation
let triggerOutput = job.data.event
let timeout = job.data.event.timeout
const metadata = triggerOutput.metadata
this._chainCount = metadata ? metadata.automationChainCount! : 0
this._appId = triggerOutput.appId as string
@ -240,7 +241,9 @@ class Orchestrator {
let loopStepNumber: any = undefined
let loopSteps: LoopStep[] | undefined = []
let metadata
let timeoutFlag = false
let wasLoopStep = false
let timeout = this._job.data.event.timeout
// check if this is a recurring automation,
if (isProdAppID(this._appId) && isRecurring(automation)) {
metadata = await this.getMetadata()
@ -251,6 +254,16 @@ class Orchestrator {
}
for (let step of automation.definition.steps) {
if (timeoutFlag) {
break
}
if (timeout) {
setTimeout(() => {
timeoutFlag = true
}, timeout || 12000)
}
stepCount++
let input: any,
iterations = 1,
@ -495,6 +508,32 @@ export function execute(job: Job, callback: WorkerCallback) {
})
}
export function executeSynchronously(job: Job) {
const appId = job.data.event.appId
if (!appId) {
throw new Error("Unable to execute, event doesn't contain app ID.")
}
const timeoutPromise = new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("Timeout exceeded"))
}, job.data.event.timeout || 12000)
})
return context.doInAppContext(appId, async () => {
const envVars = await sdkUtils.getEnvironmentVariables()
// put into automation thread for whole context
return context.doInEnvironmentContext(envVars, async () => {
const automationOrchestrator = new Orchestrator(job)
const response = await Promise.race([
automationOrchestrator.execute(),
timeoutPromise,
])
return response
})
})
}
export const removeStalled = async (job: Job) => {
const appId = job.data.event.appId
if (!appId) {

View File

@ -57,6 +57,7 @@ export enum AutomationActionStepId {
FILTER = "FILTER",
QUERY_ROWS = "QUERY_ROWS",
LOOP = "LOOP",
COLLECT = "COLLECT",
OPENAI = "OPENAI",
// these used to be lowercase step IDs, maintain for backwards compat
discord = "discord",
@ -123,6 +124,11 @@ export interface AutomationStepSchema {
outputs: InputOutputBlock
}
custom?: boolean
features?: Partial<Record<AutomationFeature, boolean>>
}
export enum AutomationFeature {
LOOPING = "LOOPING",
}
export interface AutomationStep extends AutomationStepSchema {

View File

@ -5,6 +5,7 @@ export interface AutomationDataEvent {
appId?: string
metadata?: AutomationMetadata
automation?: Automation
timeout?: number
}
export interface AutomationData {

View File

@ -8,6 +8,7 @@ export enum Feature {
ENFORCEABLE_SSO = "enforceableSSO",
BRANDING = "branding",
SCIM = "scim",
SYNC_AUTOMATIONS = "syncAutomations",
}
export type PlanFeatures = { [key in PlanType]: Feature[] | undefined }