diff --git a/packages/builder/assets/n8n_square.png b/packages/builder/assets/n8n_square.png new file mode 100644 index 0000000000..23b75ee688 Binary files /dev/null and b/packages/builder/assets/n8n_square.png differ diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte index a8711d220b..72cedb2b21 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte @@ -128,10 +128,10 @@ >
zapier diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ExternalActions.js b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ExternalActions.js index c6f8d25640..d5d382485c 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ExternalActions.js +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ExternalActions.js @@ -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 }, } diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 7ba1c8a4b1..707317f9e6 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -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 @@ {getFieldLabel(key, value)} {/if}
diff --git a/packages/builder/src/constants/backend/automations.js b/packages/builder/src/constants/backend/automations.js index 6981418fa7..7c3e17e225 100644 --- a/packages/builder/src/constants/backend/automations.js +++ b/packages/builder/src/constants/backend/automations.js @@ -27,6 +27,7 @@ export const ActionStepID = { slack: "slack", zapier: "zapier", integromat: "integromat", + n8n: "n8n", } export const Features = { diff --git a/packages/server/src/automations/actions.ts b/packages/server/src/automations/actions.ts index ac8a340e82..eee8ab4a7b 100644 --- a/packages/server/src/automations/actions.ts +++ b/packages/server/src/automations/actions.ts @@ -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 = { @@ -70,6 +72,7 @@ export const BUILTIN_ACTION_DEFINITIONS: Record = slack: slack.definition, zapier: zapier.definition, integromat: make.definition, + n8n: n8n.definition, } // don't add the bash script/definitions unless in self host diff --git a/packages/server/src/automations/steps/make.ts b/packages/server/src/automations/steps/make.ts index 06e96907d9..555df8308a 100644 --- a/packages/server/src/automations/steps/make.ts +++ b/packages/server/src/automations/steps/make.ts @@ -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: { diff --git a/packages/server/src/automations/steps/n8n.ts b/packages/server/src/automations/steps/n8n.ts new file mode 100644 index 0000000000..c400c7037a --- /dev/null +++ b/packages/server/src/automations/steps/n8n.ts @@ -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, + } +} diff --git a/packages/server/src/automations/steps/zapier.ts b/packages/server/src/automations/steps/zapier.ts index eeff0c2c7d..e48d677228 100644 --- a/packages/server/src/automations/steps/zapier.ts +++ b/packages/server/src/automations/steps/zapier.ts @@ -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"], }, diff --git a/packages/server/src/automations/tests/n8n.spec.ts b/packages/server/src/automations/tests/n8n.spec.ts new file mode 100644 index 0000000000..d60a08b53b --- /dev/null +++ b/packages/server/src/automations/tests/n8n.spec.ts @@ -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) + }) +}) diff --git a/packages/server/src/integrations/rest.ts b/packages/server/src/integrations/rest.ts index 9cb8f8e2c1..44c62f60b7 100644 --- a/packages/server/src/integrations/rest.ts +++ b/packages/server/src/integrations/rest.ts @@ -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 }) } } diff --git a/packages/types/src/documents/app/automation.ts b/packages/types/src/documents/app/automation.ts index 91a1a2ab68..fef72b78a9 100644 --- a/packages/types/src/documents/app/automation.ts +++ b/packages/types/src/documents/app/automation.ts @@ -69,6 +69,7 @@ export enum AutomationActionStepId { slack = "slack", zapier = "zapier", integromat = "integromat", + n8n = "n8n", } export interface EmailInvite { diff --git a/packages/types/src/documents/app/query.ts b/packages/types/src/documents/app/query.ts index 790c297813..81aa90b807 100644 --- a/packages/types/src/documents/app/query.ts +++ b/packages/types/src/documents/app/query.ts @@ -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", +}