Merge pull request #787 from Budibase/feature/webhooks

Webhooks for automations
This commit is contained in:
Michael Drury 2020-10-27 14:24:30 +00:00 committed by GitHub
commit a3717ce041
31 changed files with 817 additions and 30 deletions

View File

@ -63,7 +63,7 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.44.1",
"@budibase/bbui": "^1.47.0",
"@budibase/client": "^0.2.6",
"@budibase/colorpicker": "^1.0.1",
"@fortawesome/fontawesome-free": "^5.14.0",

View File

@ -11,12 +11,20 @@ const automationActions = store => ({
])
const jsonResponses = await Promise.all(responses.map(x => x.json()))
store.update(state => {
let selected = state.selectedAutomation?.automation
state.automations = jsonResponses[0]
state.blockDefinitions = {
TRIGGER: jsonResponses[1].trigger,
ACTION: jsonResponses[1].action,
LOGIC: jsonResponses[1].logic,
}
// if previously selected find the new obj and select it
if (selected) {
selected = jsonResponses[0].filter(
automation => automation._id === selected._id
)
state.selectedAutomation = new Automation(selected[0])
}
return state
})
},

View File

@ -1,11 +1,18 @@
<script>
import { automationStore } from "builderStore"
import { backendUiStore, automationStore } from "builderStore"
import CreateWebookModal from "../../Shared/CreateWebhookModal.svelte"
import analytics from "analytics"
import { Modal } from "@budibase/bbui"
export let blockDefinition
export let stepId
export let blockType
let modal
$: instanceId = $backendUiStore.selectedDatabase._id
$: automation = $automationStore.selectedAutomation?.automation
function addBlockToAutomation() {
automationStore.actions.addBlockToAutomation({
...blockDefinition,
@ -13,6 +20,9 @@
stepId,
type: blockType,
})
if (stepId === "WEBHOOK") {
modal.show()
}
analytics.captureEvent("Added Automation Block", {
name: blockDefinition.name,
})
@ -29,6 +39,9 @@
<p>{blockDefinition.description}</p>
</div>
</div>
<Modal bind:this={modal} width="30%">
<CreateWebookModal />
</Modal>
<style>
.automation-block {
@ -62,6 +75,7 @@
}
.automation-text p {
font-size: 12px;
line-height: 1.4;
color: var(--grey-7);
margin: 0;
}

View File

@ -1,12 +1,15 @@
<script>
import TableSelector from "./ParamInputs/TableSelector.svelte"
import RowSelector from "./ParamInputs/RowSelector.svelte"
import { Input, TextArea, Select, Label } from "@budibase/bbui"
import { Button, Input, TextArea, Select, Label } from "@budibase/bbui"
import { automationStore } from "builderStore"
import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
import BindableInput from "../../userInterface/BindableInput.svelte"
export let block
export let webhookModal
$: inputs = Object.entries(block.schema?.inputs?.properties || {})
$: stepId = block.stepId
$: bindings = getAvailableBindings(
block,
$automationStore.selectedAutomation?.automation?.definition
@ -64,6 +67,8 @@
<TableSelector bind:value={block.inputs[key]} />
{:else if value.customType === 'row'}
<RowSelector bind:value={block.inputs[key]} {bindings} />
{:else if value.customType === 'webhookUrl'}
<WebhookDisplay value={block.inputs[key]} />
{:else if value.type === 'string' || value.type === 'number'}
<BindableInput
type="string"
@ -73,6 +78,11 @@
{/if}
</div>
{/each}
{#if stepId === 'WEBHOOK'}
<Button wide secondary on:click={() => webhookModal.show()}>
Setup webhook
</Button>
{/if}
</div>
<style>

View File

@ -2,11 +2,13 @@
import { backendUiStore, automationStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import AutomationBlockSetup from "./AutomationBlockSetup.svelte"
import { Button, Input, Label } from "@budibase/bbui"
import { Button, Input, Label, Modal } from "@budibase/bbui"
import CreateWebookModal from "../Shared/CreateWebhookModal.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
let selectedTab = "SETUP"
let confirmDeleteDialog
let webhookModal
$: instanceId = $backendUiStore.selectedDatabase._id
$: automation = $automationStore.selectedAutomation?.automation
@ -60,7 +62,9 @@
</header>
<div class="content">
{#if $automationStore.selectedBlock}
<AutomationBlockSetup bind:block={$automationStore.selectedBlock} />
<AutomationBlockSetup
bind:block={$automationStore.selectedBlock}
{webhookModal} />
{:else if $automationStore.selectedAutomation}
<div class="block-label">Automation <b>{automation.name}</b></div>
<Button secondary wide on:click={testAutomation}>Test Automation</Button>
@ -102,6 +106,9 @@
body={`Are you sure you wish to delete the automation '${name}'?`}
okText="Delete Automation"
onOk={deleteAutomation} />
<Modal bind:this={webhookModal} width="30%">
<CreateWebookModal />
</Modal>
<style>
section {

View File

@ -0,0 +1,110 @@
<script>
import { store, backendUiStore, automationStore } from "builderStore"
import WebhookDisplay from "./WebhookDisplay.svelte"
import { ModalContent } from "@budibase/bbui"
import { onMount, onDestroy } from "svelte"
import { cloneDeep } from "lodash/fp"
import analytics from "analytics"
const POLL_RATE_MS = 2500
const DEFAULT_SCHEMA_OUTPUT = "Any input allowed"
let name
let interval
let finished = false
let schemaURL
let propCount = 0
$: instanceId = $backendUiStore.selectedDatabase._id
$: appId = $store.appId
$: automation = $automationStore.selectedAutomation?.automation
onMount(async () => {
// save the automation initially
await automationStore.actions.save({
instanceId,
automation,
})
interval = setInterval(async () => {
await automationStore.actions.fetch()
const outputs = automation?.definition?.trigger.schema.outputs?.properties
// always one prop for the "body"
if (Object.keys(outputs).length > 1) {
propCount = Object.keys(outputs).length - 1
finished = true
}
}, POLL_RATE_MS)
schemaURL = automation?.definition?.trigger?.inputs.schemaUrl
})
onDestroy(() => {
clearInterval(interval)
})
</script>
<ModalContent
title="Webhook Setup"
confirmText="Finished"
showConfirmButton={finished}
cancelText="Skip">
<p>
Webhooks are for receiving data. To make them easier please use the URL
shown below and send a
<code>POST</code>
request to it from your other application. If you're unable to do this now
then you can skip this step, however we will not be able to configure
bindings for your later actions!
</p>
<WebhookDisplay value={schemaURL} />
{#if finished}
<p class="finished-text">
Request received! We found
{propCount}
bindable value{propCount > 1 ? 's' : ''}.
</p>
{/if}
<div slot="footer">
<a target="_blank" href="https://docs.budibase.com/automate/steps/triggers">
<i class="ri-information-line" />
<span>Learn about webhooks</span>
</a>
</div>
</ModalContent>
<style>
a {
color: var(--ink);
font-size: 14px;
vertical-align: middle;
display: flex;
align-items: center;
text-decoration: none;
}
a span {
text-decoration: underline;
}
i {
font-size: 20px;
margin-right: var(--spacing-m);
text-decoration: none;
}
p {
margin-top: 0;
padding-top: 0;
text-align: justify;
}
.finished-text {
font-weight: 500;
text-align: center;
color: var(--blue);
}
h5 {
margin: 0;
}
code {
padding: 1px 4px 1px 4px;
font-size: 14px;
color: var(--grey-7);
background-color: var(--grey-4);
border-radius: 2px;
}
</style>

View File

@ -0,0 +1,64 @@
<script>
import { notifier } from "builderStore/store/notifications"
import { Input } from "@budibase/bbui"
import { store } from "../../../builderStore"
export let value
export let production = false
$: appId = $store.appId
function fullWebhookURL(uri) {
if (production) {
return `https://${appId}.app.budi.live/${uri}`
} else {
return `http://localhost:4001/${uri}`
}
}
function copyToClipboard() {
const dummy = document.createElement("textarea")
document.body.appendChild(dummy)
dummy.value = fullWebhookURL(value)
dummy.select()
document.execCommand("copy")
document.body.removeChild(dummy)
notifier.success(`URL copied to clipboard`)
}
</script>
<div>
<Input disabled="true" thin value={fullWebhookURL(value)} />
<span on:click={() => copyToClipboard()}>
<i class="ri-clipboard-line copy-icon" />
</span>
</div>
<style>
div {
position: relative;
}
div :global(input:disabled) {
color: var(--grey-7);
}
span {
position: absolute;
border: none;
border-radius: 50%;
height: 24px;
width: 24px;
background: white;
right: var(--spacing-s);
bottom: 9px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
span:hover {
background-color: var(--grey-3);
}
</style>

View File

@ -0,0 +1,76 @@
<script>
import { automationStore } from "builderStore"
import { ModalContent } from "@budibase/bbui"
import { onMount } from "svelte"
import WebhookDisplay from "../automation/Shared/WebhookDisplay.svelte"
import analytics from "analytics"
let webhookUrls = []
$: automations = $automationStore.automations
onMount(() => {
webhookUrls = automations.map(automation => {
const trigger = automation.definition.trigger
if (trigger?.stepId === "WEBHOOK" && trigger.inputs) {
return {
type: "Automation",
name: automation.name,
url: trigger.inputs.triggerUrl,
}
}
})
})
</script>
<ModalContent title="Webhook Endpoints" confirmText="Done">
<p>See below the list of deployed webhook URLs.</p>
{#each webhookUrls as webhookUrl}
<div>
<h5>{webhookUrl.type} - {webhookUrl.name}</h5>
<WebhookDisplay value={webhookUrl.url} production={true} />
</div>
{/each}
{#if webhookUrls.length === 0}
<h5>No webhooks found.</h5>
{/if}
<div slot="footer">
<a target="_blank" href="https://docs.budibase.com/automate/steps/triggers">
<i class="ri-information-line" />
<span>Learn about webhooks</span>
</a>
</div>
</ModalContent>
<style>
a {
color: var(--ink);
font-size: 14px;
vertical-align: middle;
display: flex;
align-items: center;
text-decoration: none;
}
a span {
text-decoration: underline;
}
i {
font-size: 20px;
margin-right: var(--spacing-m);
text-decoration: none;
}
p {
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
text-align: justify;
}
h5 {
margin-top: 0;
padding-top: 0;
}
</style>

View File

@ -2,9 +2,10 @@
import { onMount, onDestroy } from "svelte"
import Spinner from "components/common/Spinner.svelte"
import { slide } from "svelte/transition"
import { Heading, Body } from "@budibase/bbui"
import { Heading, Body, Button, Modal } from "@budibase/bbui"
import api from "builderStore/api"
import { notifier } from "builderStore/store/notifications"
import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte"
const DATE_OPTIONS = {
fullDate: {
@ -23,6 +24,7 @@
export let appId
let modal
let poll
let deployments = []
let deploymentUrl = `https://${appId}.app.budi.live/${appId}`
@ -52,9 +54,12 @@
<section class="deployment-history" in:slide>
<header>
<h4>Deployment History</h4>
<div class="deploy-div">
<a target="_blank" href={`https://${appId}.app.budi.live/${appId}`}>
View Your Deployed App →
</a>
<Button primary on:click={() => modal.show()}>View webhooks</Button>
</div>
</header>
<div class="deployment-list">
{#each deployments as deployment}
@ -80,6 +85,9 @@
</div>
</section>
{/if}
<Modal bind:this={modal} width="30%">
<CreateWebhookDeploymentModal />
</Modal>
<style>
.deployment:nth-child(odd) {
@ -99,6 +107,13 @@
header {
margin-left: var(--spacing-l);
margin-bottom: var(--spacing-xl);
margin-right: var(--spacing-l);
}
.deploy-div {
display: flex;
justify-content: space-between;
align-items: center;
}
.deployment-history {

View File

@ -18,10 +18,21 @@
export let align
export let popover = null
let getCaretPosition
$: categories = Object.entries(groupBy("category", bindings))
function onClickBinding(binding) {
value += `{{ ${binding.path} }}`
const position = getCaretPosition()
const toAdd = `{{ ${binding.path} }}`
if (position.start) {
value =
value.substring(0, position.start) +
toAdd +
value.substring(position.end, value.length)
} else {
value += toAdd
}
}
</script>
@ -54,6 +65,7 @@
</Body>
<TextArea
thin
bind:getCaretPosition
bind:value
placeholder="Add options from the left, type text, or do both" />
<div class="controls">

View File

@ -709,19 +709,19 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"
"@budibase/bbui@^1.44.1":
version "1.44.1"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.44.1.tgz#1bfca2d3a40a14eb0ba136e24afb7139694b4970"
integrity sha512-joFH++mzFJZkMR3ZSv8Q8cE/qNn8o8aZGbHeO57Smiai3h2tjeTyVLuJ+yt3P1XQ8SE13epAp38kvsPosUpbkQ==
"@budibase/bbui@^1.47.0":
version "1.47.0"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.47.0.tgz#f7c1f1efff12b2a62eb52536fcc9a037f9b25982"
integrity sha512-mWOglrEjKSOe7At2gA8HDv5MUvPzFrpGgiikAeMEulvE7sq/SCreXtAps/Jx+RKq/tUMEZkDoA3S5nuQhsNM/A==
dependencies:
sirv-cli "^0.4.6"
svelte-flatpickr "^2.4.0"
svelte-portal "^1.0.0"
"@budibase/client@^0.2.4":
version "0.2.4"
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-0.2.4.tgz#da958faa50c59f6a9c41c692b7a19d6a6ea98bc1"
integrity sha512-MsFbWcsh3t1lyLgTb4UMccjshy6jd3A77lqs1CpXjHr+2LmXwvIriLgruycAvFrtqZzYG+dGe0rWwX0auwaaZw==
"@budibase/client@^0.2.6":
version "0.2.6"
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-0.2.6.tgz#de1b4872c7956d386a3b08969eda509bd39d1a64"
integrity sha512-sSoGN0k2Tcc5GewBjFMN+G3h21O9JvakYI33kBKgEVGrdEQLBbry7vRKb+lALeW2Bz65gafZL2joZzL8vnH0lw==
dependencies:
deep-equal "^2.0.1"
mustache "^4.0.1"

View File

@ -57,8 +57,10 @@
"electron-updater": "^4.3.1",
"fix-path": "^3.0.0",
"fs-extra": "^8.1.0",
"handlebars": "^4.7.6",
"jimp": "^0.16.1",
"joi": "^17.2.1",
"jsonschema": "^1.4.0",
"jsonwebtoken": "^8.5.1",
"koa": "^2.7.0",
"koa-body": "^4.2.0",
@ -77,6 +79,7 @@
"sanitize-s3-objectkey": "^0.0.1",
"squirrelly": "^7.5.0",
"tar-fs": "^2.1.0",
"to-json-schema": "^0.2.5",
"uuid": "^3.3.2",
"validate.js": "^0.13.1",
"worker-farm": "^1.7.0",

View File

@ -2,8 +2,10 @@ const CouchDB = require("../../db")
const actions = require("../../automations/actions")
const logic = require("../../automations/logic")
const triggers = require("../../automations/triggers")
const webhooks = require("./webhook")
const { getAutomationParams, generateAutomationID } = require("../../db/utils")
const WH_STEP_ID = triggers.BUILTIN_DEFINITIONS.WEBHOOK.stepId
/*************************
* *
* BUILDER FUNCTIONS *
@ -30,6 +32,67 @@ function cleanAutomationInputs(automation) {
return automation
}
/**
* This function handles checking if any webhooks need to be created or deleted for automations.
* @param {object} user The user object, including all auth info
* @param {object|undefined} oldAuto The old automation object if updating/deleting
* @param {object|undefined} newAuto The new automation object if creating/updating
* @returns {Promise<object|undefined>} After this is complete the new automation object may have been updated and should be
* written to DB (this does not write to DB as it would be wasteful to repeat).
*/
async function checkForWebhooks({ user, oldAuto, newAuto }) {
const oldTrigger = oldAuto ? oldAuto.definition.trigger : null
const newTrigger = newAuto ? newAuto.definition.trigger : null
function isWebhookTrigger(auto) {
return (
auto &&
auto.definition.trigger &&
auto.definition.trigger.stepId === WH_STEP_ID
)
}
// need to delete webhook
if (
isWebhookTrigger(oldAuto) &&
!isWebhookTrigger(newAuto) &&
oldTrigger.webhookId
) {
let db = new CouchDB(user.instanceId)
// need to get the webhook to get the rev
const webhook = await db.get(oldTrigger.webhookId)
const ctx = {
user,
params: { id: webhook._id, rev: webhook._rev },
}
// might be updating - reset the inputs to remove the URLs
if (newTrigger) {
delete newTrigger.webhookId
newTrigger.inputs = {}
}
await webhooks.destroy(ctx)
}
// need to create webhook
else if (!isWebhookTrigger(oldAuto) && isWebhookTrigger(newAuto)) {
const ctx = {
user,
request: {
body: new webhooks.Webhook(
"Automation webhook",
webhooks.WebhookType.AUTOMATION,
newAuto._id
),
},
}
await webhooks.save(ctx)
const id = ctx.body.webhook._id
newTrigger.webhookId = id
newTrigger.inputs = {
schemaUrl: `api/webhooks/schema/${user.instanceId}/${id}`,
triggerUrl: `api/webhooks/trigger/${user.instanceId}/${id}`,
}
}
return newAuto
}
exports.create = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
let automation = ctx.request.body
@ -39,7 +102,8 @@ exports.create = async function(ctx) {
automation.type = "automation"
automation = cleanAutomationInputs(automation)
const response = await db.post(automation)
automation = await checkForWebhooks({ user: ctx.user, newAuto: automation })
const response = await db.put(automation)
automation._rev = response.rev
ctx.status = 200
@ -56,8 +120,13 @@ exports.update = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
let automation = ctx.request.body
automation.appId = ctx.user.appId
const oldAutomation = await db.get(automation._id)
automation = cleanAutomationInputs(automation)
automation = await checkForWebhooks({
user: ctx.user,
oldAuto: oldAutomation,
newAuto: automation,
})
const response = await db.put(automation)
automation._rev = response.rev
@ -89,6 +158,8 @@ exports.find = async function(ctx) {
exports.destroy = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
const oldAutomation = await db.get(ctx.params.id)
await checkForWebhooks({ user: ctx.user, oldAuto: oldAutomation })
ctx.body = await db.remove(ctx.params.id, ctx.params.rev)
}

View File

@ -0,0 +1,99 @@
const CouchDB = require("../../db")
const { generateWebhookID, getWebhookParams } = require("../../db/utils")
const toJsonSchema = require("to-json-schema")
const validate = require("jsonschema").validate
const triggers = require("../../automations/triggers")
const AUTOMATION_DESCRIPTION = "Generated from Webhook Schema"
function Webhook(name, type, target) {
this.live = true
this.name = name
this.action = {
type,
target,
}
}
exports.Webhook = Webhook
exports.WebhookType = {
AUTOMATION: "automation",
}
exports.fetch = async ctx => {
const db = new CouchDB(ctx.user.instanceId)
const response = await db.allDocs(
getWebhookParams(null, {
include_docs: true,
})
)
ctx.body = response.rows.map(row => row.doc)
}
exports.save = async ctx => {
const db = new CouchDB(ctx.user.instanceId)
const webhook = ctx.request.body
webhook.appId = ctx.user.appId
// check that the webhook exists
if (webhook._id) {
await db.get(webhook._id)
} else {
webhook._id = generateWebhookID()
}
const response = await db.put(webhook)
ctx.body = {
message: "Webhook created successfully",
webhook: {
...webhook,
...response,
},
}
}
exports.destroy = async ctx => {
const db = new CouchDB(ctx.user.instanceId)
ctx.body = await db.remove(ctx.params.id, ctx.params.rev)
}
exports.buildSchema = async ctx => {
const db = new CouchDB(ctx.params.instance)
const webhook = await db.get(ctx.params.id)
webhook.bodySchema = toJsonSchema(ctx.request.body)
// update the automation outputs
if (webhook.action.type === exports.WebhookType.AUTOMATION) {
let automation = await db.get(webhook.action.target)
const autoOutputs = automation.definition.trigger.schema.outputs
let properties = webhook.bodySchema.properties
for (let prop of Object.keys(properties)) {
autoOutputs.properties[prop] = {
type: properties[prop].type,
description: AUTOMATION_DESCRIPTION,
}
}
await db.put(automation)
}
ctx.body = await db.put(webhook)
}
exports.trigger = async ctx => {
const db = new CouchDB(ctx.params.instance)
const webhook = await db.get(ctx.params.id)
// validate against the schema
if (webhook.bodySchema) {
validate(ctx.request.body, webhook.bodySchema)
}
const target = await db.get(webhook.action.target)
if (webhook.action.type === exports.WebhookType.AUTOMATION) {
// trigger with both the pure request and then expand it
// incase the user has produced a schema to bind to
await triggers.externalTrigger(target, {
body: ctx.request.body,
...ctx.request.body,
instanceId: ctx.params.instance,
})
}
ctx.status = 200
ctx.body = "Webhook trigger fired successfully"
}

View File

@ -22,6 +22,7 @@ const {
apiKeysRoutes,
templatesRoutes,
analyticsRoutes,
webhookRoutes,
} = require("./routes")
const router = new Router()
@ -88,6 +89,9 @@ router.use(instanceRoutes.allowedMethods())
router.use(automationRoutes.routes())
router.use(automationRoutes.allowedMethods())
router.use(webhookRoutes.routes())
router.use(webhookRoutes.allowedMethods())
router.use(deployRoutes.routes())
router.use(deployRoutes.allowedMethods())

View File

@ -2,7 +2,7 @@ const Router = require("@koa/router")
const controller = require("../controllers/automation")
const authorized = require("../../middleware/authorized")
const joiValidator = require("../../middleware/joi-validator")
const { BUILDER } = require("../../utilities/accessLevels")
const { BUILDER, EXECUTE_AUTOMATION } = require("../../utilities/accessLevels")
const Joi = require("joi")
const router = Router()
@ -33,7 +33,7 @@ function generateValidator(existing = false) {
type: Joi.string().valid("automation").required(),
definition: Joi.object({
steps: Joi.array().required().items(generateStepSchema(["ACTION", "LOGIC"])),
trigger: generateStepSchema(["TRIGGER"]),
trigger: generateStepSchema(["TRIGGER"]).allow(null),
}).required().unknown(true),
}).unknown(true))
}
@ -73,7 +73,11 @@ router
generateValidator(false),
controller.create
)
.post("/api/automations/:id/trigger", controller.trigger)
.post(
"/api/automations/:id/trigger",
authorized(EXECUTE_AUTOMATION),
controller.trigger
)
.delete("/api/automations/:id/:rev", authorized(BUILDER), controller.destroy)
module.exports = router

View File

@ -10,6 +10,7 @@ const viewRoutes = require("./view")
const staticRoutes = require("./static")
const componentRoutes = require("./component")
const automationRoutes = require("./automation")
const webhookRoutes = require("./webhook")
const accesslevelRoutes = require("./accesslevel")
const deployRoutes = require("./deploy")
const apiKeysRoutes = require("./apikeys")
@ -34,4 +35,5 @@ module.exports = {
apiKeysRoutes,
templatesRoutes,
analyticsRoutes,
webhookRoutes,
}

View File

@ -0,0 +1,45 @@
const Router = require("@koa/router")
const controller = require("../controllers/webhook")
const authorized = require("../../middleware/authorized")
const joiValidator = require("../../middleware/joi-validator")
const { BUILDER, EXECUTE_WEBHOOK } = require("../../utilities/accessLevels")
const Joi = require("joi")
const router = Router()
function generateSaveValidator() {
// prettier-ignore
return joiValidator.body(Joi.object({
live: Joi.bool(),
_id: Joi.string().optional(),
_rev: Joi.string().optional(),
name: Joi.string().required(),
bodySchema: Joi.object().optional(),
action: Joi.object({
type: Joi.string().required().valid(controller.WebhookType.AUTOMATION),
target: Joi.string().required(),
}).required(),
}).unknown(true))
}
router
.get("/api/webhooks", authorized(BUILDER), controller.fetch)
.put(
"/api/webhooks",
authorized(BUILDER),
generateSaveValidator(),
controller.save
)
.delete("/api/webhooks/:id/:rev", authorized(BUILDER), controller.destroy)
.post(
"/api/webhooks/schema/:instance/:id",
authorized(BUILDER),
controller.buildSchema
)
.post(
"/api/webhooks/trigger/:instance/:id",
authorized(EXECUTE_WEBHOOK),
controller.trigger
)
module.exports = router

View File

@ -3,6 +3,7 @@ const createRow = require("./steps/createRow")
const updateRow = require("./steps/updateRow")
const deleteRow = require("./steps/deleteRow")
const createUser = require("./steps/createUser")
const outgoingWebhook = require("./steps/outgoingWebhook")
const environment = require("../environment")
const download = require("download")
const fetch = require("node-fetch")
@ -21,6 +22,7 @@ const BUILTIN_ACTIONS = {
UPDATE_ROW: updateRow.run,
DELETE_ROW: deleteRow.run,
CREATE_USER: createUser.run,
OUTGOING_WEBHOOK: outgoingWebhook.run,
}
const BUILTIN_DEFINITIONS = {
SEND_EMAIL: sendEmail.definition,
@ -28,6 +30,7 @@ const BUILTIN_DEFINITIONS = {
UPDATE_ROW: updateRow.definition,
DELETE_ROW: deleteRow.definition,
CREATE_USER: createUser.definition,
OUTGOING_WEBHOOK: outgoingWebhook.definition,
}
let AUTOMATION_BUCKET = environment.AUTOMATION_BUCKET

View File

@ -6,7 +6,7 @@ const usage = require("../../utilities/usageQuota")
module.exports.definition = {
name: "Create Row",
tagline: "Create a {{inputs.enriched.table.name}} row",
icon: "ri-save-3-fill",
icon: "ri-save-3-line",
description: "Add a row to your database",
type: "ACTION",
stepId: "CREATE_ROW",

View File

@ -6,7 +6,7 @@ const usage = require("../../utilities/usageQuota")
module.exports.definition = {
description: "Create a new user",
tagline: "Create user {{inputs.username}}",
icon: "ri-user-add-fill",
icon: "ri-user-add-line",
name: "Create User",
type: "ACTION",
stepId: "CREATE_USER",

View File

@ -2,7 +2,7 @@ let { wait } = require("../../utilities")
module.exports.definition = {
name: "Delay",
icon: "ri-time-fill",
icon: "ri-time-line",
tagline: "Delay for {{inputs.time}} milliseconds",
description: "Delay the automation until an amount of time has passed",
stepId: "DELAY",

View File

@ -0,0 +1,95 @@
const fetch = require("node-fetch")
const RequestType = {
POST: "POST",
GET: "GET",
PUT: "PUT",
DELETE: "DELETE",
PATCH: "PATCH",
}
const BODY_REQUESTS = [RequestType.POST, RequestType.PUT, RequestType.PATCH]
/**
* Note, there is some functionality in this that is not currently exposed as it
* is complex and maybe better to be opinionated here.
* GET/DELETE requests cannot handle body elements so they will not be sent if configured.
*/
module.exports.definition = {
name: "Outgoing webhook",
tagline: "Send a {{inputs.requestMethod}} request",
icon: "ri-send-plane-line",
description: "Send a request of specified method to a URL",
type: "ACTION",
stepId: "OUTGOING_WEBHOOK",
inputs: {
requestMethod: "POST",
url: "http://",
requestBody: "{}",
},
schema: {
inputs: {
properties: {
requestMethod: {
type: "string",
enum: Object.values(RequestType),
title: "Request method",
},
url: {
type: "string",
title: "URL",
},
requestBody: {
type: "string",
title: "JSON Body",
customType: "wide",
},
},
required: ["requestMethod", "url"],
},
outputs: {
properties: {
response: {
type: "object",
description: "The response from the webhook",
},
success: {
type: "boolean",
description: "Whether the action was successful",
},
},
required: ["response", "success"],
},
},
}
module.exports.run = async function({ inputs }) {
let { requestMethod, url, requestBody } = inputs
if (!url.startsWith("http")) {
url = `http://${url}`
}
const request = {
method: requestMethod,
}
if (
requestBody &&
requestBody.length !== 0 &&
BODY_REQUESTS.indexOf(requestMethod) !== -1
) {
request.body = JSON.parse(requestBody)
}
try {
const response = await fetch(url, request)
return {
response: await response.json(),
success: response.status === 200,
}
} catch (err) {
return {
success: false,
response: err,
}
}
}

View File

@ -1,7 +1,7 @@
module.exports.definition = {
description: "Send an email",
tagline: "Send email to {{inputs.to}}",
icon: "ri-mail-open-fill",
icon: "ri-mail-open-line",
name: "Send Email",
type: "ACTION",
stepId: "SEND_EMAIL",

View File

@ -4,7 +4,7 @@ const automationUtils = require("../automationUtils")
module.exports.definition = {
name: "Update Row",
tagline: "Update a {{inputs.enriched.table.name}} row",
icon: "ri-refresh-fill",
icon: "ri-refresh-line",
description: "Update a row in your database",
type: "ACTION",
stepId: "UPDATE_ROW",

View File

@ -1,8 +1,12 @@
const mustache = require("mustache")
const handlebars = require("handlebars")
const actions = require("./actions")
const logic = require("./logic")
const automationUtils = require("./automationUtils")
handlebars.registerHelper("object", value => {
return new handlebars.SafeString(JSON.stringify(value))
})
const FILTER_STEP_ID = logic.BUILTIN_DEFINITIONS.FILTER.stepId
function recurseMustache(inputs, context) {
@ -10,7 +14,8 @@ function recurseMustache(inputs, context) {
let val = inputs[key]
if (typeof val === "string") {
val = automationUtils.cleanMustache(inputs[key])
inputs[key] = mustache.render(val, context)
const template = handlebars.compile(val)
inputs[key] = template(context)
}
// this covers objects and arrays
else if (typeof val === "object") {

View File

@ -83,6 +83,42 @@ const BUILTIN_DEFINITIONS = {
},
type: "TRIGGER",
},
WEBHOOK: {
name: "Webhook",
event: "web:trigger",
icon: "ri-global-line",
tagline: "Webhook endpoint is hit",
description: "Trigger an automation when a HTTP POST webhook is hit",
stepId: "WEBHOOK",
inputs: {},
schema: {
inputs: {
properties: {
schemaUrl: {
type: "string",
customType: "webhookUrl",
title: "Schema URL",
},
triggerUrl: {
type: "string",
customType: "webhookUrl",
title: "Trigger URL",
},
},
required: ["schemaUrl", "triggerUrl"],
},
outputs: {
properties: {
body: {
type: "object",
description: "Body of the request which hit the webhook",
},
},
required: ["body"],
},
},
type: "TRIGGER",
},
}
async function queueRelevantRowAutomations(event, eventType) {

View File

@ -11,6 +11,7 @@ const DocumentTypes = {
LINK: "li",
APP: "app",
ACCESS_LEVEL: "ac",
WEBHOOK: "wh",
}
exports.DocumentTypes = DocumentTypes
@ -164,3 +165,18 @@ exports.generateAccessLevelID = () => {
exports.getAccessLevelParams = (accessLevelId = null, otherProps = {}) => {
return getDocParams(DocumentTypes.ACCESS_LEVEL, accessLevelId, otherProps)
}
/**
* Generates a new webhook ID.
* @returns {string} The new webhook ID which the webhook doc can be stored under.
*/
exports.generateWebhookID = () => {
return `${DocumentTypes.WEBHOOK}${SEPARATOR}${newid()}`
}
/**
* Gets parameters for retrieving a webhook, this is a utility function for the getDocParams function.
*/
exports.getWebhookParams = (webhookId = null, otherProps = {}) => {
return getDocParams(DocumentTypes.WEBHOOK, webhookId, otherProps)
}

View File

@ -9,7 +9,13 @@ const environment = require("../environment")
const { apiKeyTable } = require("../db/dynamoClient")
const { AuthTypes } = require("../constants")
const LOCAL_PASS = new RegExp(["webhooks/trigger", "webhooks/schema"].join("|"))
module.exports = (permName, getItemId) => async (ctx, next) => {
// webhooks can pass locally
if (!environment.CLOUD && LOCAL_PASS.test(ctx.request.url)) {
return next()
}
if (
environment.CLOUD &&
ctx.headers["x-api-key"] &&

View File

@ -3,6 +3,7 @@ module.exports.READ_TABLE = "read-table"
module.exports.WRITE_TABLE = "write-table"
module.exports.READ_VIEW = "read-view"
module.exports.EXECUTE_AUTOMATION = "execute-automation"
module.exports.EXECUTE_WEBHOOK = "execute-webhook"
module.exports.USER_MANAGEMENT = "user-management"
module.exports.BUILDER = "builder"
module.exports.LIST_USERS = "list-users"

View File

@ -1352,6 +1352,13 @@ axios@^0.19.2:
dependencies:
follow-redirects "1.5.10"
axios@^0.21.0:
version "0.21.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.0.tgz#26df088803a2350dff2c27f96fef99fe49442aca"
integrity sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==
dependencies:
follow-redirects "^1.10.0"
babel-jest@^24.9.0:
version "24.9.0"
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-24.9.0.tgz#3fc327cb8467b89d14d7bc70e315104a783ccd54"
@ -3178,6 +3185,11 @@ follow-redirects@1.5.10:
dependencies:
debug "=3.1.0"
follow-redirects@^1.10.0:
version "1.13.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
for-in@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@ -3482,6 +3494,18 @@ growly@^1.3.0:
resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=
handlebars@^4.7.6:
version "4.7.6"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.6.tgz#d4c05c1baf90e9945f77aa68a7a219aa4a7df74e"
integrity sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==
dependencies:
minimist "^1.2.5"
neo-async "^2.6.0"
source-map "^0.6.1"
wordwrap "^1.0.0"
optionalDependencies:
uglify-js "^3.1.4"
har-schema@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
@ -4714,6 +4738,11 @@ jsonfile@^6.0.1:
optionalDependencies:
graceful-fs "^4.1.6"
jsonschema@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.0.tgz#1afa34c4bc22190d8e42271ec17ac8b3404f87b2"
integrity sha512-/YgW6pRMr6M7C+4o8kS+B/2myEpHCrxO4PEWnqJNBFMjn7EWXqlQ4tGwL6xTHeRplwuZmcAncdvfOad1nT2yMw==
jsonwebtoken@^8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
@ -5160,6 +5189,21 @@ lodash.isstring@^4.0.1:
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=
lodash.keys@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-4.2.0.tgz#a08602ac12e4fb83f91fc1fb7a360a4d9ba35205"
integrity sha1-oIYCrBLk+4P5H8H7ejYKTZujUgU=
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash.omit@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60"
integrity sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA=
lodash.once@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
@ -5175,6 +5219,16 @@ lodash.sortby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
lodash.without@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.without/-/lodash.without-4.4.0.tgz#3cd4574a00b67bae373a94b748772640507b7aac"
integrity sha1-PNRXSgC2e643OpS3SHcmQFB7eqw=
lodash.xor@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.xor/-/lodash.xor-4.5.0.tgz#4d48ed7e98095b0632582ba714d3ff8ae8fb1db6"
integrity sha1-TUjtfpgJWwYyWCunFNP/iuj7HbY=
lodash@^4.17.10, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.3:
version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
@ -5469,6 +5523,11 @@ negotiator@0.6.2:
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
neo-async@^2.6.0:
version "2.6.2"
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
nice-try@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
@ -7471,6 +7530,18 @@ to-fast-properties@^2.0.0:
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
to-json-schema@^0.2.5:
version "0.2.5"
resolved "https://registry.yarnpkg.com/to-json-schema/-/to-json-schema-0.2.5.tgz#ef3c3f11ad64460dcfbdbafd0fd525d69d62a98f"
integrity sha512-jP1ievOee8pec3tV9ncxLSS48Bnw7DIybgy112rhMCEhf3K4uyVNZZHr03iQQBzbV5v5Hos+dlZRRyk6YSMNDw==
dependencies:
lodash.isequal "^4.5.0"
lodash.keys "^4.2.0"
lodash.merge "^4.6.2"
lodash.omit "^4.5.0"
lodash.without "^4.4.0"
lodash.xor "^4.5.0"
to-object-path@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
@ -7632,6 +7703,11 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
uglify-js@^3.1.4:
version "3.11.4"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.11.4.tgz#b47b7ae99d4bd1dca65b53aaa69caa0909e6fadf"
integrity sha512-FyYnoxVL1D6+jDGQpbK5jW6y/2JlVfRfEeQ67BPCUg5wfCjaKOpr2XeceE4QL+MkhxliLtf5EbrMDZgzpt2CNw==
unbzip2-stream@^1.0.9:
version "1.4.3"
resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7"
@ -7942,6 +8018,11 @@ word-wrap@~1.2.3:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
wordwrap@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=
worker-farm@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8"