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:
parent
dea72b5818
commit
649dafba47
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
|
@ -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">
|
||||||
|
|
|
@ -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 },
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)}>
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"],
|
||||||
},
|
},
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue