n8n automation action integration (#12992)

* Add n8n automation action

* Add authorization header support

* add unit tests

* Replace test.com with example.com

* Add HttpMethod enum to types

* fix unit test

* Add required field label asterisk
This commit is contained in:
melohagan 2024-02-15 13:05:03 +00:00 committed by GitHub
parent dea72b5818
commit 649dafba47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 232 additions and 56 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -128,10 +128,10 @@
> >
<div class="item-body"> <div class="item-body">
<img <img
width="20" width={20}
height="20" height={20}
src={externalActions[action.stepId].icon} src={externalActions[action.stepId].icon}
alt="zapier" alt={externalActions[action.stepId].name}
/> />
<span class="icon-spacing"> <span class="icon-spacing">
<Body size="XS"> <Body size="XS">

View File

@ -1,5 +1,6 @@
import DiscordLogo from "assets/discord.svg" import DiscordLogo from "assets/discord.svg"
import ZapierLogo from "assets/zapier.png" import ZapierLogo from "assets/zapier.png"
import n8nLogo from "assets/n8n_square.png"
import MakeLogo from "assets/make.svg" import MakeLogo from "assets/make.svg"
import SlackLogo from "assets/slack.svg" import SlackLogo from "assets/slack.svg"
@ -8,4 +9,5 @@ export const externalActions = {
discord: { name: "discord", icon: DiscordLogo }, discord: { name: "discord", icon: DiscordLogo },
slack: { name: "slack", icon: SlackLogo }, slack: { name: "slack", icon: SlackLogo },
integromat: { name: "integromat", icon: MakeLogo }, integromat: { name: "integromat", icon: MakeLogo },
n8n: { name: "n8n", icon: n8nLogo },
} }

View File

@ -79,6 +79,7 @@
disableWrapping: true, disableWrapping: true,
}) })
$: editingJs = codeMode === EditorModes.JS $: editingJs = codeMode === EditorModes.JS
$: requiredProperties = block.schema.inputs.required || []
$: stepCompletions = $: stepCompletions =
codeMode === EditorModes.Handlebars codeMode === EditorModes.Handlebars
@ -359,6 +360,11 @@
) )
} }
function getFieldLabel(key, value) {
const requiredSuffix = requiredProperties.includes(key) ? "*" : ""
return `${value.title || (key === "row" ? "Table" : key)} ${requiredSuffix}`
}
onMount(async () => { onMount(async () => {
try { try {
await environment.loadVariables() await environment.loadVariables()
@ -376,7 +382,7 @@
<Label <Label
tooltip={value.title === "Binding / Value" tooltip={value.title === "Binding / Value"
? "If using the String input type, please use a comma or newline separated string" ? "If using the String input type, please use a comma or newline separated string"
: null}>{value.title || (key === "row" ? "Table" : key)}</Label : null}>{getFieldLabel(key, value)}</Label
> >
{/if} {/if}
<div class:field-width={shouldRenderField(value)}> <div class:field-width={shouldRenderField(value)}>

View File

@ -27,6 +27,7 @@ export const ActionStepID = {
slack: "slack", slack: "slack",
zapier: "zapier", zapier: "zapier",
integromat: "integromat", integromat: "integromat",
n8n: "n8n",
} }
export const Features = { export const Features = {

View File

@ -9,6 +9,7 @@ import * as serverLog from "./steps/serverLog"
import * as discord from "./steps/discord" import * as discord from "./steps/discord"
import * as slack from "./steps/slack" import * as slack from "./steps/slack"
import * as zapier from "./steps/zapier" import * as zapier from "./steps/zapier"
import * as n8n from "./steps/n8n"
import * as make from "./steps/make" import * as make from "./steps/make"
import * as filter from "./steps/filter" import * as filter from "./steps/filter"
import * as delay from "./steps/delay" import * as delay from "./steps/delay"
@ -48,6 +49,7 @@ const ACTION_IMPLS: Record<
slack: slack.run, slack: slack.run,
zapier: zapier.run, zapier: zapier.run,
integromat: make.run, integromat: make.run,
n8n: n8n.run,
} }
export const BUILTIN_ACTION_DEFINITIONS: Record<string, AutomationStepSchema> = export const BUILTIN_ACTION_DEFINITIONS: Record<string, AutomationStepSchema> =
{ {
@ -70,6 +72,7 @@ export const BUILTIN_ACTION_DEFINITIONS: Record<string, AutomationStepSchema> =
slack: slack.definition, slack: slack.definition,
zapier: zapier.definition, zapier: zapier.definition,
integromat: make.definition, integromat: make.definition,
n8n: n8n.definition,
} }
// don't add the bash script/definitions unless in self host // don't add the bash script/definitions unless in self host

View File

@ -34,28 +34,8 @@ export const definition: AutomationStepSchema = {
type: AutomationIOType.JSON, type: AutomationIOType.JSON,
title: "Payload", title: "Payload",
}, },
value1: {
type: AutomationIOType.STRING,
title: "Input Value 1",
},
value2: {
type: AutomationIOType.STRING,
title: "Input Value 2",
},
value3: {
type: AutomationIOType.STRING,
title: "Input Value 3",
},
value4: {
type: AutomationIOType.STRING,
title: "Input Value 4",
},
value5: {
type: AutomationIOType.STRING,
title: "Input Value 5",
},
}, },
required: ["url", "value1", "value2", "value3", "value4", "value5"], required: ["url", "body"],
}, },
outputs: { outputs: {
properties: { properties: {

View File

@ -0,0 +1,125 @@
import fetch, { HeadersInit } from "node-fetch"
import { getFetchResponse } from "./utils"
import {
AutomationActionStepId,
AutomationStepSchema,
AutomationStepInput,
AutomationStepType,
AutomationIOType,
AutomationFeature,
HttpMethod,
} from "@budibase/types"
export const definition: AutomationStepSchema = {
name: "n8n Integration",
stepTitle: "n8n",
tagline: "Trigger an n8n workflow",
description:
"Performs a webhook call to n8n and gets the response (if configured)",
icon: "ri-shut-down-line",
stepId: AutomationActionStepId.n8n,
type: AutomationStepType.ACTION,
internal: false,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {},
schema: {
inputs: {
properties: {
url: {
type: AutomationIOType.STRING,
title: "Webhook URL",
},
method: {
type: AutomationIOType.STRING,
title: "Method",
enum: Object.values(HttpMethod),
},
authorization: {
type: AutomationIOType.STRING,
title: "Authorization",
},
body: {
type: AutomationIOType.JSON,
title: "Payload",
},
},
required: ["url", "method"],
},
outputs: {
properties: {
success: {
type: AutomationIOType.BOOLEAN,
description: "Whether call was successful",
},
httpStatus: {
type: AutomationIOType.NUMBER,
description: "The HTTP status code returned",
},
response: {
type: AutomationIOType.OBJECT,
description: "The webhook response - this can have properties",
},
},
required: ["success", "response"],
},
},
}
export async function run({ inputs }: AutomationStepInput) {
const { url, body, method, authorization } = inputs
let payload = {}
try {
payload = body?.value ? JSON.parse(body?.value) : {}
} catch (err) {
return {
httpStatus: 400,
response: "Invalid payload JSON",
success: false,
}
}
if (!url?.trim()?.length) {
return {
httpStatus: 400,
response: "Missing Webhook URL",
success: false,
}
}
let response
let request: {
method: string
headers: HeadersInit
body?: string
} = {
method: method || HttpMethod.GET,
headers: {
"Content-Type": "application/json",
Authorization: authorization,
},
}
if (!["GET", "HEAD"].includes(request.method)) {
request.body = JSON.stringify({
...payload,
})
}
try {
response = await fetch(url, request)
} catch (err: any) {
return {
httpStatus: 400,
response: err.message,
success: false,
}
}
const { status, message } = await getFetchResponse(response)
return {
httpStatus: status,
success: status === 200,
response: message,
}
}

View File

@ -32,26 +32,6 @@ export const definition: AutomationStepSchema = {
type: AutomationIOType.JSON, type: AutomationIOType.JSON,
title: "Payload", title: "Payload",
}, },
value1: {
type: AutomationIOType.STRING,
title: "Payload Value 1",
},
value2: {
type: AutomationIOType.STRING,
title: "Payload Value 2",
},
value3: {
type: AutomationIOType.STRING,
title: "Payload Value 3",
},
value4: {
type: AutomationIOType.STRING,
title: "Payload Value 4",
},
value5: {
type: AutomationIOType.STRING,
title: "Payload Value 5",
},
}, },
required: ["url"], required: ["url"],
}, },

View File

@ -0,0 +1,68 @@
import { getConfig, afterAll, runStep, actions } from "./utilities"
describe("test the outgoing webhook action", () => {
let config = getConfig()
beforeAll(async () => {
await config.init()
})
afterAll()
it("should be able to run the action and default to 'get'", async () => {
const res = await runStep(actions.n8n.stepId, {
url: "http://www.example.com",
body: {
test: "IGNORE_ME",
},
})
expect(res.response.url).toEqual("http://www.example.com")
expect(res.response.method).toEqual("GET")
expect(res.response.body).toBeUndefined()
expect(res.success).toEqual(true)
})
it("should add the payload props when a JSON string is provided", async () => {
const payload = `{ "name": "Adam", "age": 9 }`
const res = await runStep(actions.n8n.stepId, {
body: {
value: payload,
},
method: "POST",
url: "http://www.example.com",
})
expect(res.response.url).toEqual("http://www.example.com")
expect(res.response.method).toEqual("POST")
expect(res.response.body).toEqual(`{"name":"Adam","age":9}`)
expect(res.success).toEqual(true)
})
it("should return a 400 if the JSON payload string is malformed", async () => {
const payload = `{ value1 1 }`
const res = await runStep(actions.n8n.stepId, {
value1: "ONE",
body: {
value: payload,
},
method: "POST",
url: "http://www.example.com",
})
expect(res.httpStatus).toEqual(400)
expect(res.response).toEqual("Invalid payload JSON")
expect(res.success).toEqual(false)
})
it("should not append the body if the method is HEAD", async () => {
const res = await runStep(actions.n8n.stepId, {
url: "http://www.example.com",
method: "HEAD",
body: {
test: "IGNORE_ME",
},
})
expect(res.response.url).toEqual("http://www.example.com")
expect(res.response.method).toEqual("HEAD")
expect(res.response.body).toBeUndefined()
expect(res.success).toEqual(true)
})
})

View File

@ -10,6 +10,7 @@ import {
RestAuthType, RestAuthType,
RestBasicAuthConfig, RestBasicAuthConfig,
RestBearerAuthConfig, RestBearerAuthConfig,
HttpMethod,
} from "@budibase/types" } from "@budibase/types"
import get from "lodash/get" import get from "lodash/get"
import * as https from "https" import * as https from "https"
@ -86,30 +87,30 @@ const SCHEMA: Integration = {
query: { query: {
create: { create: {
readable: true, readable: true,
displayName: "POST", displayName: HttpMethod.POST,
type: QueryType.FIELDS, type: QueryType.FIELDS,
fields: coreFields, fields: coreFields,
}, },
read: { read: {
displayName: "GET", displayName: HttpMethod.GET,
readable: true, readable: true,
type: QueryType.FIELDS, type: QueryType.FIELDS,
fields: coreFields, fields: coreFields,
}, },
update: { update: {
displayName: "PUT", displayName: HttpMethod.PUT,
readable: true, readable: true,
type: QueryType.FIELDS, type: QueryType.FIELDS,
fields: coreFields, fields: coreFields,
}, },
patch: { patch: {
displayName: "PATCH", displayName: HttpMethod.PATCH,
readable: true, readable: true,
type: QueryType.FIELDS, type: QueryType.FIELDS,
fields: coreFields, fields: coreFields,
}, },
delete: { delete: {
displayName: "DELETE", displayName: HttpMethod.DELETE,
type: QueryType.FIELDS, type: QueryType.FIELDS,
fields: coreFields, fields: coreFields,
}, },
@ -358,7 +359,7 @@ class RestIntegration implements IntegrationBase {
path = "", path = "",
queryString = "", queryString = "",
headers = {}, headers = {},
method = "GET", method = HttpMethod.GET,
disabledHeaders, disabledHeaders,
bodyType, bodyType,
requestBody, requestBody,
@ -413,23 +414,23 @@ class RestIntegration implements IntegrationBase {
} }
async create(opts: RestQuery) { async create(opts: RestQuery) {
return this._req({ ...opts, method: "POST" }) return this._req({ ...opts, method: HttpMethod.POST })
} }
async read(opts: RestQuery) { async read(opts: RestQuery) {
return this._req({ ...opts, method: "GET" }) return this._req({ ...opts, method: HttpMethod.GET })
} }
async update(opts: RestQuery) { async update(opts: RestQuery) {
return this._req({ ...opts, method: "PUT" }) return this._req({ ...opts, method: HttpMethod.PUT })
} }
async patch(opts: RestQuery) { async patch(opts: RestQuery) {
return this._req({ ...opts, method: "PATCH" }) return this._req({ ...opts, method: HttpMethod.PATCH })
} }
async delete(opts: RestQuery) { async delete(opts: RestQuery) {
return this._req({ ...opts, method: "DELETE" }) return this._req({ ...opts, method: HttpMethod.DELETE })
} }
} }

View File

@ -69,6 +69,7 @@ export enum AutomationActionStepId {
slack = "slack", slack = "slack",
zapier = "zapier", zapier = "zapier",
integromat = "integromat", integromat = "integromat",
n8n = "n8n",
} }
export interface EmailInvite { export interface EmailInvite {

View File

@ -64,3 +64,12 @@ export interface ExecuteQueryRequest {
export interface ExecuteQueryResponse { export interface ExecuteQueryResponse {
data: Row[] data: Row[]
} }
export enum HttpMethod {
GET = "GET",
POST = "POST",
PATCH = "PATCH",
PUT = "PUT",
HEAD = "HEAD",
DELETE = "DELETE",
}