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">
|
||||
<img
|
||||
width="20"
|
||||
height="20"
|
||||
width={20}
|
||||
height={20}
|
||||
src={externalActions[action.stepId].icon}
|
||||
alt="zapier"
|
||||
alt={externalActions[action.stepId].name}
|
||||
/>
|
||||
<span class="icon-spacing">
|
||||
<Body size="XS">
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import DiscordLogo from "assets/discord.svg"
|
||||
import ZapierLogo from "assets/zapier.png"
|
||||
import n8nLogo from "assets/n8n_square.png"
|
||||
import MakeLogo from "assets/make.svg"
|
||||
import SlackLogo from "assets/slack.svg"
|
||||
|
||||
|
@ -8,4 +9,5 @@ export const externalActions = {
|
|||
discord: { name: "discord", icon: DiscordLogo },
|
||||
slack: { name: "slack", icon: SlackLogo },
|
||||
integromat: { name: "integromat", icon: MakeLogo },
|
||||
n8n: { name: "n8n", icon: n8nLogo },
|
||||
}
|
||||
|
|
|
@ -79,6 +79,7 @@
|
|||
disableWrapping: true,
|
||||
})
|
||||
$: editingJs = codeMode === EditorModes.JS
|
||||
$: requiredProperties = block.schema.inputs.required || []
|
||||
|
||||
$: stepCompletions =
|
||||
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 () => {
|
||||
try {
|
||||
await environment.loadVariables()
|
||||
|
@ -376,7 +382,7 @@
|
|||
<Label
|
||||
tooltip={value.title === "Binding / Value"
|
||||
? "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}
|
||||
<div class:field-width={shouldRenderField(value)}>
|
||||
|
|
|
@ -27,6 +27,7 @@ export const ActionStepID = {
|
|||
slack: "slack",
|
||||
zapier: "zapier",
|
||||
integromat: "integromat",
|
||||
n8n: "n8n",
|
||||
}
|
||||
|
||||
export const Features = {
|
||||
|
|
|
@ -9,6 +9,7 @@ import * as serverLog from "./steps/serverLog"
|
|||
import * as discord from "./steps/discord"
|
||||
import * as slack from "./steps/slack"
|
||||
import * as zapier from "./steps/zapier"
|
||||
import * as n8n from "./steps/n8n"
|
||||
import * as make from "./steps/make"
|
||||
import * as filter from "./steps/filter"
|
||||
import * as delay from "./steps/delay"
|
||||
|
@ -48,6 +49,7 @@ const ACTION_IMPLS: Record<
|
|||
slack: slack.run,
|
||||
zapier: zapier.run,
|
||||
integromat: make.run,
|
||||
n8n: n8n.run,
|
||||
}
|
||||
export const BUILTIN_ACTION_DEFINITIONS: Record<string, AutomationStepSchema> =
|
||||
{
|
||||
|
@ -70,6 +72,7 @@ export const BUILTIN_ACTION_DEFINITIONS: Record<string, AutomationStepSchema> =
|
|||
slack: slack.definition,
|
||||
zapier: zapier.definition,
|
||||
integromat: make.definition,
|
||||
n8n: n8n.definition,
|
||||
}
|
||||
|
||||
// don't add the bash script/definitions unless in self host
|
||||
|
|
|
@ -34,28 +34,8 @@ export const definition: AutomationStepSchema = {
|
|||
type: AutomationIOType.JSON,
|
||||
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: {
|
||||
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,
|
||||
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"],
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
RestBasicAuthConfig,
|
||||
RestBearerAuthConfig,
|
||||
HttpMethod,
|
||||
} from "@budibase/types"
|
||||
import get from "lodash/get"
|
||||
import * as https from "https"
|
||||
|
@ -86,30 +87,30 @@ const SCHEMA: Integration = {
|
|||
query: {
|
||||
create: {
|
||||
readable: true,
|
||||
displayName: "POST",
|
||||
displayName: HttpMethod.POST,
|
||||
type: QueryType.FIELDS,
|
||||
fields: coreFields,
|
||||
},
|
||||
read: {
|
||||
displayName: "GET",
|
||||
displayName: HttpMethod.GET,
|
||||
readable: true,
|
||||
type: QueryType.FIELDS,
|
||||
fields: coreFields,
|
||||
},
|
||||
update: {
|
||||
displayName: "PUT",
|
||||
displayName: HttpMethod.PUT,
|
||||
readable: true,
|
||||
type: QueryType.FIELDS,
|
||||
fields: coreFields,
|
||||
},
|
||||
patch: {
|
||||
displayName: "PATCH",
|
||||
displayName: HttpMethod.PATCH,
|
||||
readable: true,
|
||||
type: QueryType.FIELDS,
|
||||
fields: coreFields,
|
||||
},
|
||||
delete: {
|
||||
displayName: "DELETE",
|
||||
displayName: HttpMethod.DELETE,
|
||||
type: QueryType.FIELDS,
|
||||
fields: coreFields,
|
||||
},
|
||||
|
@ -358,7 +359,7 @@ class RestIntegration implements IntegrationBase {
|
|||
path = "",
|
||||
queryString = "",
|
||||
headers = {},
|
||||
method = "GET",
|
||||
method = HttpMethod.GET,
|
||||
disabledHeaders,
|
||||
bodyType,
|
||||
requestBody,
|
||||
|
@ -413,23 +414,23 @@ class RestIntegration implements IntegrationBase {
|
|||
}
|
||||
|
||||
async create(opts: RestQuery) {
|
||||
return this._req({ ...opts, method: "POST" })
|
||||
return this._req({ ...opts, method: HttpMethod.POST })
|
||||
}
|
||||
|
||||
async read(opts: RestQuery) {
|
||||
return this._req({ ...opts, method: "GET" })
|
||||
return this._req({ ...opts, method: HttpMethod.GET })
|
||||
}
|
||||
|
||||
async update(opts: RestQuery) {
|
||||
return this._req({ ...opts, method: "PUT" })
|
||||
return this._req({ ...opts, method: HttpMethod.PUT })
|
||||
}
|
||||
|
||||
async patch(opts: RestQuery) {
|
||||
return this._req({ ...opts, method: "PATCH" })
|
||||
return this._req({ ...opts, method: HttpMethod.PATCH })
|
||||
}
|
||||
|
||||
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",
|
||||
zapier = "zapier",
|
||||
integromat = "integromat",
|
||||
n8n = "n8n",
|
||||
}
|
||||
|
||||
export interface EmailInvite {
|
||||
|
|
|
@ -64,3 +64,12 @@ export interface ExecuteQueryRequest {
|
|||
export interface ExecuteQueryResponse {
|
||||
data: Row[]
|
||||
}
|
||||
|
||||
export enum HttpMethod {
|
||||
GET = "GET",
|
||||
POST = "POST",
|
||||
PATCH = "PATCH",
|
||||
PUT = "PUT",
|
||||
HEAD = "HEAD",
|
||||
DELETE = "DELETE",
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue