forked from tsai/budibase
committed by
GitHub
31 changed files with 817 additions and 30 deletions
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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" |
||||
|
} |
||||
@ -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 |
||||
@ -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, |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue