From 7e95f9e2e40a99871f1b6abcdacb39ac5f857332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 6 Aug 2024 15:16:33 +0200 Subject: [PATCH 01/19] fix(core): VM2 sandbox should not throw on `new Promise` (#10298) --- packages/@n8n/nodes-langchain/package.json | 2 +- .../nodes/Code/test/Code.workflow.json | 66 +++++++++++++++++++ packages/nodes-base/package.json | 2 +- pnpm-lock.yaml | 29 ++++---- 4 files changed, 79 insertions(+), 20 deletions(-) diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 3a328af0ae880..899f5d016ff4d 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -153,7 +153,7 @@ "@langchain/textsplitters": "0.0.3", "@mozilla/readability": "^0.5.0", "@n8n/typeorm": "0.3.20-10", - "@n8n/vm2": "3.9.24", + "@n8n/vm2": "3.9.25", "@pinecone-database/pinecone": "3.0.0", "@qdrant/js-client-rest": "1.9.0", "@supabase/supabase-js": "2.43.4", diff --git a/packages/nodes-base/nodes/Code/test/Code.workflow.json b/packages/nodes-base/nodes/Code/test/Code.workflow.json index 07933b0f824a9..fbb4199956213 100644 --- a/packages/nodes-base/nodes/Code/test/Code.workflow.json +++ b/packages/nodes-base/nodes/Code/test/Code.workflow.json @@ -59,6 +59,34 @@ "type": "n8n-nodes-base.code", "typeVersion": 1, "position": [460, 860] + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const json = $input.item.json\njson.myNewField = await (async () => json.value)();\n\nreturn $input.item;" + }, + "id": "3cff4a64-c3fd-47d3-a33e-3c446846138f", + "name": "With Async Functions", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [ + 460, + 1200 + ] + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const json = $input.item.json\njson.myNewField = await new Promise((resolve) => resolve(json.value));\n\nreturn $input.item;" + }, + "id": "947e4e3e-2da3-40c5-97da-830c4572fc05", + "name": "With Promises", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [ + 460, + 1380 + ] } ], "pinData": { @@ -103,6 +131,34 @@ "myNewField": 2 } } + ], + "With Async Functions": [ + { + "json": { + "value": 1, + "myNewField": 1 + } + }, + { + "json": { + "value": 2, + "myNewField": 2 + } + } + ], + "With Promises": [ + { + "json": { + "value": 1, + "myNewField": 1 + } + }, + { + "json": { + "value": 2, + "myNewField": 2 + } + } ] }, "connections": { @@ -139,6 +195,16 @@ "node": "Run Once for Each Item (Legacy Syntax)", "type": "main", "index": 0 + }, + { + "node": "With Async Functions", + "type": "main", + "index": 0 + }, + { + "node": "With Promises", + "type": "main", + "index": 0 } ] ] diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e9da85e9faf47..d3c73d1f04ed0 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -830,7 +830,7 @@ "dependencies": { "@kafkajs/confluent-schema-registry": "1.0.6", "@n8n/imap": "workspace:*", - "@n8n/vm2": "3.9.24", + "@n8n/vm2": "3.9.25", "amqplib": "0.10.3", "alasql": "^4.4.0", "aws4": "1.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8287287e22db9..a392d7c5672fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -390,8 +390,8 @@ importers: specifier: 0.3.20-10 version: 0.3.20-10(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.10.0)(pg@8.11.3)(redis@4.6.12)(sqlite3@5.1.7) '@n8n/vm2': - specifier: 3.9.24 - version: 3.9.24 + specifier: 3.9.25 + version: 3.9.25 '@pinecone-database/pinecone': specifier: 3.0.0 version: 3.0.0 @@ -1448,8 +1448,8 @@ importers: specifier: workspace:* version: link:../@n8n/imap '@n8n/vm2': - specifier: 3.9.24 - version: 3.9.24 + specifier: 3.9.25 + version: 3.9.25 alasql: specifier: ^4.4.0 version: 4.4.0(encoding@0.1.13) @@ -4211,8 +4211,8 @@ packages: typeorm-aurora-data-api-driver: optional: true - '@n8n/vm2@3.9.24': - resolution: {integrity: sha512-O4z67yVgUs2FHkcw3vbGnxdC1EglpzOj966kPkK4gtW+ZmTTFRfEB+2Ehq6PMthgg/Ou5JCLSR3wvQIZFFt4Pg==} + '@n8n/vm2@3.9.25': + resolution: {integrity: sha512-qoGLFzyHBW7HKpwXkl05QKsIh3GkDw6lOiTOWYlUDnOIQ1b7EgM+O5EMjrMGy7r+kz52+Q7o6GLxBIcxVI8rEg==} engines: {node: '>=18.10', pnpm: '>=9.6'} hasBin: true @@ -6008,11 +6008,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acorn@8.11.2: - resolution: {integrity: sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.12.1: resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} engines: {node: '>=0.4.0'} @@ -16710,7 +16705,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@n8n/vm2@3.9.24': + '@n8n/vm2@3.9.25': dependencies: acorn: 8.12.1 acorn-walk: 8.3.2 @@ -19360,16 +19355,14 @@ snapshots: acorn: 8.12.1 acorn-walk: 8.3.2 - acorn-jsx@5.3.2(acorn@8.11.2): + acorn-jsx@5.3.2(acorn@8.12.1): dependencies: - acorn: 8.11.2 + acorn: 8.12.1 acorn-walk@8.3.2: {} acorn@7.4.1: {} - acorn@8.11.2: {} - acorn@8.12.1: {} address@1.2.2: {} @@ -21481,8 +21474,8 @@ snapshots: espree@9.6.1: dependencies: - acorn: 8.11.2 - acorn-jsx: 5.3.2(acorn@8.11.2) + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) eslint-visitor-keys: 3.4.3 esprima-next@5.8.4: {} From 74554d21d78292992fd8bb51d80d511ce840f695 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Tue, 6 Aug 2024 15:28:00 +0200 Subject: [PATCH 02/19] fix(editor): Update project tabs test (no-changelog) (#10300) --- .../src/components/N8nTabs/Tabs.vue | 8 +-- .../components/Projects/ProjectTabs.test.ts | 56 +++++++------------ 2 files changed, 24 insertions(+), 40 deletions(-) diff --git a/packages/design-system/src/components/N8nTabs/Tabs.vue b/packages/design-system/src/components/N8nTabs/Tabs.vue index 1cd2792c9957f..967f08a430701 100644 --- a/packages/design-system/src/components/N8nTabs/Tabs.vue +++ b/packages/design-system/src/components/N8nTabs/Tabs.vue @@ -13,7 +13,7 @@ :key="option.value" :class="{ [$style.alignRight]: option.align === 'right' }" > - + @@ -31,14 +31,14 @@ - {{ option.label }} - +
{{ option.label }}
-
+ diff --git a/packages/editor-ui/src/components/Projects/ProjectTabs.test.ts b/packages/editor-ui/src/components/Projects/ProjectTabs.test.ts index 91c197a794ace..e31e7caeefbfc 100644 --- a/packages/editor-ui/src/components/Projects/ProjectTabs.test.ts +++ b/packages/editor-ui/src/components/Projects/ProjectTabs.test.ts @@ -1,7 +1,8 @@ -import { createComponentRenderer } from '@/__tests__/render'; -import ProjectTabs from '@/components/Projects/ProjectTabs.vue'; +import { createPinia, setActivePinia } from 'pinia'; import { useRoute } from 'vue-router'; +import { createComponentRenderer } from '@/__tests__/render'; import { createTestProject } from '@/__tests__/data/projects'; +import ProjectTabs from '@/components/Projects/ProjectTabs.vue'; import { useProjectsStore } from '@/stores/projects.store'; vi.mock('vue-router', () => { @@ -17,28 +18,25 @@ vi.mock('vue-router', () => { RouterLink: vi.fn(), }; }); - -vi.mock('@/stores/users.store', () => ({ - useUsersStore: vi.fn().mockImplementation(() => ({ - currentUser: {}, - })), -})); - -vi.mock('@/stores/projects.store', () => ({ - useProjectsStore: vi.fn().mockReturnValue({}), -})); - -vi.mock('@/utils/rbac/permissions', () => ({ - hasPermission: vi.fn().mockReturnValue(false), -})); - -const renderComponent = createComponentRenderer(ProjectTabs); +const renderComponent = createComponentRenderer(ProjectTabs, { + global: { + stubs: { + 'router-link': { + template: '
', + }, + }, + }, +}); let route: ReturnType; +let projectsStore: ReturnType; describe('ProjectTabs', () => { beforeEach(() => { + const pinia = createPinia(); + setActivePinia(pinia); route = useRoute(); + projectsStore = useProjectsStore(); }); it('should render home tabs', async () => { @@ -49,16 +47,9 @@ describe('ProjectTabs', () => { expect(queryByText('Project settings')).not.toBeInTheDocument(); }); - it('should render project tabs if use has permissions', () => { + it('should render project tab Settings if user has permissions', () => { route.params.projectId = '123'; - vi.mocked(useProjectsStore).mockImplementationOnce( - () => - ({ - currentProject: createTestProject({ - scopes: ['project:update'], - }), - }) as ReturnType, - ); + projectsStore.setCurrentProject(createTestProject({ scopes: ['project:update'] })); const { getByText } = renderComponent(); expect(getByText('Workflows')).toBeInTheDocument(); @@ -66,16 +57,9 @@ describe('ProjectTabs', () => { expect(getByText('Project settings')).toBeInTheDocument(); }); - it('should render project tabs', () => { + it('should render project tabs without Settings if no permission', () => { route.params.projectId = '123'; - vi.mocked(useProjectsStore).mockImplementationOnce( - () => - ({ - currentProject: createTestProject({ - scopes: ['project:read'], - }), - }) as ReturnType, - ); + projectsStore.setCurrentProject(createTestProject({ scopes: ['project:read'] })); const { queryByText, getByText } = renderComponent(); expect(getByText('Workflows')).toBeInTheDocument(); From 557a76ec2326de72fb7a8b46fc4353f8fd9b591d Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Tue, 6 Aug 2024 15:29:55 +0200 Subject: [PATCH 03/19] fix(editor): Update tags filter/editor to not show non existing tag as a selectable option (#10297) --- cypress/e2e/17-workflow-tags.cy.ts | 17 +++++++++++++++++ .../components/MainHeader/WorkflowDetails.vue | 1 - .../editor-ui/src/components/TagsDropdown.vue | 13 ++++--------- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/cypress/e2e/17-workflow-tags.cy.ts b/cypress/e2e/17-workflow-tags.cy.ts index 88a2c9973dd66..fc889aead29c2 100644 --- a/cypress/e2e/17-workflow-tags.cy.ts +++ b/cypress/e2e/17-workflow-tags.cy.ts @@ -1,4 +1,5 @@ import { WorkflowPage } from '../pages'; +import { getVisibleSelect } from '../utils'; const wf = new WorkflowPage(); @@ -70,4 +71,20 @@ describe('Workflow tags', () => { wf.getters.workflowTags().click(); wf.getters.tagPills().should('have.length', TEST_TAGS.length - 1); }); + + it('should not show non existing tag as a selectable option', () => { + const NON_EXISTING_TAG = 'My Test Tag'; + + wf.getters.createTagButton().click(); + wf.actions.addTags(TEST_TAGS); + cy.get('body').click(0, 0); + wf.getters.workflowTags().click(); + wf.getters.tagsDropdown().find('input:focus').type(NON_EXISTING_TAG); + + getVisibleSelect() + .find('li') + .should('have.length', 2) + .filter(`:contains("${NON_EXISTING_TAG}")`) + .should('not.have.length'); + }); }); diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index fb103720206e5..9a5eb14dfbb5c 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -639,7 +639,6 @@ function showCreateWorkflowSuccessToast(id?: string) { v-if="isTagsEditEnabled && !readOnly" ref="dropdown" v-model="appliedTagIds" - :create-enabled="true" :event-bus="tagsEventBus" :placeholder="$locale.baseText('workflowDetails.chooseOrCreateATag')" class="tags-edit" diff --git a/packages/editor-ui/src/components/TagsDropdown.vue b/packages/editor-ui/src/components/TagsDropdown.vue index 0a5ac3b4933a3..bcd02fd6fa482 100644 --- a/packages/editor-ui/src/components/TagsDropdown.vue +++ b/packages/editor-ui/src/components/TagsDropdown.vue @@ -13,7 +13,6 @@ :filter-method="filterOptions" filterable multiple - :allow-create="createEnabled" :reserve-keyword="false" loading-text="..." popper-class="tags-dropdown" @@ -23,7 +22,7 @@ @remove-tag="onRemoveTag" > - {{ i18n.baseText('tagsDropdown.typeToCreateATag') }} - {{ + {{ i18n.baseText('tagsDropdown.typeToCreateATag') }} + {{ i18n.baseText('tagsDropdown.noMatchingTagsExist') }} - {{ i18n.baseText('tagsDropdown.noTagsExist') }} + {{ i18n.baseText('tagsDropdown.noTagsExist') }} @@ -90,10 +89,6 @@ export default defineComponent({ type: Array as PropType, default: () => [], }, - createEnabled: { - type: Boolean, - default: false, - }, eventBus: { type: Object as PropType, default: null, From 46bbf09beacad12472d91786b91d845fe2afb26d Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Tue, 6 Aug 2024 15:31:03 +0200 Subject: [PATCH 04/19] fix(editor): Update design system Avatar component to show initials also when only firstName or lastName is given (#10308) --- .../src/components/N8nAvatar/Avatar.vue | 15 ++++++----- .../N8nAvatar/__tests__/Avatar.test.ts | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 packages/design-system/src/components/N8nAvatar/__tests__/Avatar.test.ts diff --git a/packages/design-system/src/components/N8nAvatar/Avatar.vue b/packages/design-system/src/components/N8nAvatar/Avatar.vue index f7993a84a0038..348af6ed4784a 100644 --- a/packages/design-system/src/components/N8nAvatar/Avatar.vue +++ b/packages/design-system/src/components/N8nAvatar/Avatar.vue @@ -1,14 +1,14 @@ @@ -38,7 +38,8 @@ const props = withDefaults(defineProps(), { ], }); -const initials = computed(() => getInitials(`${props.firstName} ${props.lastName}`)); +const name = computed(() => `${props.firstName} ${props.lastName}`.trim()); +const initials = computed(() => getInitials(name.value)); const getColors = (colors: string[]): string[] => { const style = getComputedStyle(document.body); @@ -62,6 +63,7 @@ const getSize = (size: string): number => sizes[size]; } .empty { + display: block; border-radius: 50%; background-color: var(--color-foreground-dark); opacity: 0.3; @@ -72,7 +74,8 @@ const getSize = (size: string): number => sizes[size]; font-size: var(--font-size-2xs); font-weight: var(--font-weight-bold); color: var(--color-avatar-font); - text-shadow: 0px 1px 6px rgba(25, 11, 9, 0.3); + text-shadow: 0 1px 6px rgba(25, 11, 9, 0.3); + text-transform: uppercase; } .small { diff --git a/packages/design-system/src/components/N8nAvatar/__tests__/Avatar.test.ts b/packages/design-system/src/components/N8nAvatar/__tests__/Avatar.test.ts new file mode 100644 index 0000000000000..4fdeef6229795 --- /dev/null +++ b/packages/design-system/src/components/N8nAvatar/__tests__/Avatar.test.ts @@ -0,0 +1,25 @@ +import { render } from '@testing-library/vue'; +import N8nAvatar from '../Avatar.vue'; + +describe('components', () => { + describe('N8nAlert', () => { + test.each([ + ['Firstname', 'Lastname', 'FL'], + ['Firstname', undefined, 'Fi'], + [undefined, 'Lastname', 'La'], + [undefined, undefined, ''], + ['', '', ''], + ])('should render initials for name "%s %s" as %s', (firstName, lastName, initials) => { + const { container, getByText } = render(N8nAvatar, { + props: { firstName, lastName }, + }); + + if (firstName || lastName) { + expect(container.querySelector('svg')).toBeVisible(); + expect(getByText(initials)).toBeVisible(); + } else { + expect(container.querySelector('svg')).not.toBeInTheDocument(); + } + }); + }); +}); From 2a8f1753e832158106d6201b21a366a8d4d12bfe Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 6 Aug 2024 15:21:57 +0100 Subject: [PATCH 05/19] docs: Fix links to license files in readme (no-changelog) (#10257) --- README.md | 4 ++-- docker/images/n8n/README.md | 4 +--- packages/@n8n/chat/README.md | 7 +------ packages/@n8n/nodes-langchain/README.md | 4 +--- packages/cli/README.md | 6 +----- packages/cli/src/PublicApi/v1/openapi.yml | 2 +- packages/core/README.md | 6 +----- packages/design-system/README.md | 6 +----- packages/editor-ui/README.md | 6 +----- packages/editor-ui/src/components/AboutModal.vue | 2 +- packages/node-dev/README.md | 6 +----- packages/nodes-base/README.md | 6 +----- packages/workflow/README.md | 6 +----- 13 files changed, 14 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 145ecab8c62d6..d51ac596cad65 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,8 @@ development environment ready in minutes. ## License n8n is [fair-code](https://faircode.io) distributed under the -[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) and the -[**n8n Enterprise License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE_EE.md). +[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) and the +[**n8n Enterprise License**](https://github.com/n8n-io/n8n/blob/master/LICENSE_EE.md). Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index d65459615086e..f7b45f9467502 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -230,6 +230,4 @@ Before you upgrade to the latest version make sure to check here if there are an ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/@n8n/chat/README.md b/packages/@n8n/chat/README.md index 538d72ce0af91..0ed53a5774082 100644 --- a/packages/@n8n/chat/README.md +++ b/packages/@n8n/chat/README.md @@ -260,10 +260,5 @@ body, ``` ## License -n8n Chat is [fair-code](https://faircode.io) distributed under the -[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license model can be found in the -[docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/@n8n/nodes-langchain/README.md b/packages/@n8n/nodes-langchain/README.md index e5761628cf67b..03a23d21865d8 100644 --- a/packages/@n8n/nodes-langchain/README.md +++ b/packages/@n8n/nodes-langchain/README.md @@ -8,6 +8,4 @@ These nodes are still in Beta state and are only compatible with the Docker imag ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/cli/README.md b/packages/cli/README.md index beeac69369b2f..526f4631676de 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -147,8 +147,4 @@ You can also find breaking changes here: [Breaking Changes](./BREAKING-CHANGES.m ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/cli/src/PublicApi/v1/openapi.yml b/packages/cli/src/PublicApi/v1/openapi.yml index 30c3a73bde0c7..0a84925734c7b 100644 --- a/packages/cli/src/PublicApi/v1/openapi.yml +++ b/packages/cli/src/PublicApi/v1/openapi.yml @@ -8,7 +8,7 @@ info: email: hello@n8n.io license: name: Sustainable Use License - url: https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md + url: https://github.com/n8n-io/n8n/blob/master/LICENSE.md version: 1.1.1 externalDocs: description: n8n API documentation diff --git a/packages/core/README.md b/packages/core/README.md index 0518567fc9bce..ad67d1a3798d6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -10,8 +10,4 @@ npm install n8n-core ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/design-system/README.md b/packages/design-system/README.md index ea69f24524a5b..c43317d077334 100644 --- a/packages/design-system/README.md +++ b/packages/design-system/README.md @@ -48,8 +48,4 @@ pnpm watch:theme ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/editor-ui/README.md b/packages/editor-ui/README.md index c8583ffbbdd31..75b8026fb8bae 100644 --- a/packages/editor-ui/README.md +++ b/packages/editor-ui/README.md @@ -56,8 +56,4 @@ See [Configuration Reference](https://cli.vuejs.org/config/). ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/editor-ui/src/components/AboutModal.vue b/packages/editor-ui/src/components/AboutModal.vue index 1aeeed0fb5715..8552044e4b30b 100644 --- a/packages/editor-ui/src/components/AboutModal.vue +++ b/packages/editor-ui/src/components/AboutModal.vue @@ -29,7 +29,7 @@ {{ $locale.baseText('about.license') }} - + {{ $locale.baseText('about.n8nLicense') }} diff --git a/packages/node-dev/README.md b/packages/node-dev/README.md index d650716a0a284..bbb999de1ba99 100644 --- a/packages/node-dev/README.md +++ b/packages/node-dev/README.md @@ -215,8 +215,4 @@ All properties are optional. However, most only work when the node-property is o ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/nodes-base/README.md b/packages/nodes-base/README.md index 3dc74130378f4..fec5cc3db06cf 100644 --- a/packages/nodes-base/README.md +++ b/packages/nodes-base/README.md @@ -10,8 +10,4 @@ npm install n8n-nodes-base -g ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/workflow/README.md b/packages/workflow/README.md index da80c579206e4..e76b4283e91e1 100644 --- a/packages/workflow/README.md +++ b/packages/workflow/README.md @@ -10,8 +10,4 @@ npm install n8n-workflow ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) From c8d322a9ba02bfe6392b5164adcbcff18fab38ec Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Wed, 7 Aug 2024 11:23:44 +0300 Subject: [PATCH 06/19] refactor(core): Reorganize webhook related components under src/webhooks (no-changelog) (#10296) --- packages/cli/src/AbstractServer.ts | 8 ++-- packages/cli/src/ActiveWorkflowManager.ts | 4 +- packages/cli/src/Interfaces.ts | 38 +------------------ packages/cli/src/WaitingForms.ts | 2 +- packages/cli/src/commands/webhook.ts | 4 +- packages/cli/src/commands/worker.ts | 2 +- .../main/handleCommandMessageMain.ts | 2 +- .../cli/src/{ => webhooks}/ActiveWebhooks.ts | 15 +++++--- .../cli/src/{ => webhooks}/TestWebhooks.ts | 22 ++++++----- .../cli/src/{ => webhooks}/WaitingWebhooks.ts | 22 ++++++----- .../cli/src/{ => webhooks}/WebhookHelpers.ts | 26 ++++++------- .../cli/src/{ => webhooks}/WebhookServer.ts | 0 .../__tests__/TestWebhooks.test.ts | 9 +++-- .../__tests__/WebhookHelpers.test.ts | 4 +- ...test-webhook-registrations.service.test.ts | 4 +- .../__tests__/waiting-webhooks.test.ts | 5 ++- .../__tests__/webhook.service.test.ts | 2 +- .../test-webhook-registrations.service.ts | 2 +- .../{services => webhooks}/webhook.service.ts | 0 packages/cli/src/webhooks/webhook.types.ts | 37 ++++++++++++++++++ .../workflows/workflowExecution.service.ts | 2 +- .../active-workflow-manager.test.ts | 4 +- .../cli/test/integration/webhooks.test.ts | 10 ++--- 23 files changed, 119 insertions(+), 105 deletions(-) rename packages/cli/src/{ => webhooks}/ActiveWebhooks.ts (91%) rename packages/cli/src/{ => webhooks}/TestWebhooks.ts (94%) rename packages/cli/src/{ => webhooks}/WaitingWebhooks.ts (88%) rename packages/cli/src/{ => webhooks}/WebhookHelpers.ts (97%) rename packages/cli/src/{ => webhooks}/WebhookServer.ts (100%) rename packages/cli/src/{ => webhooks}/__tests__/TestWebhooks.test.ts (93%) rename packages/cli/src/{ => webhooks}/__tests__/WebhookHelpers.test.ts (97%) rename packages/cli/src/{services => webhooks}/__tests__/test-webhook-registrations.service.test.ts (95%) rename packages/cli/src/{ => webhooks}/__tests__/waiting-webhooks.test.ts (90%) rename packages/cli/src/{services => webhooks}/__tests__/webhook.service.test.ts (99%) rename packages/cli/src/{services => webhooks}/test-webhook-registrations.service.ts (97%) rename packages/cli/src/{services => webhooks}/webhook.service.ts (100%) create mode 100644 packages/cli/src/webhooks/webhook.types.ts diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index ab150e2947328..38cc327831c16 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -13,15 +13,15 @@ import { N8nInstanceType } from '@/Interfaces'; import { ExternalHooks } from '@/ExternalHooks'; import { send, sendErrorResponse } from '@/ResponseHelper'; import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares'; -import { TestWebhooks } from '@/TestWebhooks'; import { WaitingForms } from '@/WaitingForms'; -import { WaitingWebhooks } from '@/WaitingWebhooks'; -import { webhookRequestHandler } from '@/WebhookHelpers'; +import { TestWebhooks } from '@/webhooks/TestWebhooks'; +import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; +import { webhookRequestHandler } from '@/webhooks/WebhookHelpers'; +import { ActiveWebhooks } from '@/webhooks/ActiveWebhooks'; import { generateHostInstanceId } from './databases/utils/generators'; import { Logger } from '@/Logger'; import { ServiceUnavailableError } from './errors/response-errors/service-unavailable.error'; import { OnShutdown } from '@/decorators/OnShutdown'; -import { ActiveWebhooks } from '@/ActiveWebhooks'; import { GlobalConfig } from '@n8n/config'; @Service() diff --git a/packages/cli/src/ActiveWorkflowManager.ts b/packages/cli/src/ActiveWorkflowManager.ts index 1d5050e4d1ca5..fac6e9d9faa2e 100644 --- a/packages/cli/src/ActiveWorkflowManager.ts +++ b/packages/cli/src/ActiveWorkflowManager.ts @@ -27,7 +27,7 @@ import { } from 'n8n-workflow'; import type { IWorkflowDb } from '@/Interfaces'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; @@ -40,7 +40,7 @@ import { } from '@/constants'; import { NodeTypes } from '@/NodeTypes'; import { ExternalHooks } from '@/ExternalHooks'; -import { WebhookService } from './services/webhook.service'; +import { WebhookService } from '@/webhooks/webhook.service'; import { Logger } from './Logger'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { OrchestrationService } from '@/services/orchestration.service'; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 8ddbd10fb29c1..9a1fdc367f4b5 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -1,4 +1,4 @@ -import type { Application, Request, Response } from 'express'; +import type { Application } from 'express'; import type { ExecutionError, ICredentialDataDecryptedObject, @@ -22,7 +22,6 @@ import type { FeatureFlags, INodeProperties, IUserSettings, - IHttpRequestMethods, StartNodeData, } from 'n8n-workflow'; @@ -239,34 +238,6 @@ export interface IExternalHooksFunctions { }; } -export type WebhookCORSRequest = Request & { method: 'OPTIONS' }; - -export type WebhookRequest = Request<{ path: string }> & { - method: IHttpRequestMethods; - params: Record; -}; - -export type WaitingWebhookRequest = WebhookRequest & { - params: WebhookRequest['path'] & { suffix?: string }; -}; - -export interface WebhookAccessControlOptions { - allowedOrigins?: string; -} - -export interface IWebhookManager { - /** Gets all request methods associated with a webhook path*/ - getWebhookMethods?: (path: string) => Promise; - - /** Find the CORS options matching a path and method */ - findAccessControlOptions?: ( - path: string, - httpMethod: IHttpRequestMethods, - ) => Promise; - - executeWebhook(req: WebhookRequest, res: Response): Promise; -} - export interface IVersionNotificationSettings { enabled: boolean; endpoint: string; @@ -466,13 +437,6 @@ export interface IPushDataWorkerStatusPayload { version: string; } -export interface IResponseCallbackData { - data?: IDataObject | IDataObject[]; - headers?: object; - noWebhookResponse?: boolean; - responseCode?: number; -} - export interface INodesTypeData { [key: string]: { className: string; diff --git a/packages/cli/src/WaitingForms.ts b/packages/cli/src/WaitingForms.ts index 0625acd7e40c4..bf0ab7dedb161 100644 --- a/packages/cli/src/WaitingForms.ts +++ b/packages/cli/src/WaitingForms.ts @@ -1,7 +1,7 @@ import { Service } from 'typedi'; import type { IExecutionResponse } from '@/Interfaces'; -import { WaitingWebhooks } from '@/WaitingWebhooks'; +import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; @Service() export class WaitingForms extends WaitingWebhooks { diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index e76ac358170c1..2c8532ec76342 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -4,7 +4,7 @@ import { ApplicationError } from 'n8n-workflow'; import config from '@/config'; import { ActiveExecutions } from '@/ActiveExecutions'; -import { WebhookServer } from '@/WebhookServer'; +import { WebhookServer } from '@/webhooks/WebhookServer'; import { Queue } from '@/Queue'; import { BaseCommand } from './BaseCommand'; @@ -86,7 +86,7 @@ export class Webhook extends BaseCommand { await this.initExternalHooks(); this.logger.debug('External hooks init complete'); await this.initExternalSecrets(); - this.logger.debug('External seecrets init complete'); + this.logger.debug('External secrets init complete'); } async run() { diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 151ecfdf90d12..5681b417273d6 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -9,7 +9,7 @@ import { Workflow, sleep, ApplicationError } from 'n8n-workflow'; import * as Db from '@/Db'; import * as ResponseHelper from '@/ResponseHelper'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import config from '@/config'; import type { Job, JobId, JobResponse, WebhookResponse } from '@/Queue'; diff --git a/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts b/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts index 7945f59bc35f8..9a7b6b5640dd0 100644 --- a/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts +++ b/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts @@ -7,7 +7,7 @@ import { License } from '@/License'; import { Logger } from '@/Logger'; import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; import { Push } from '@/push'; -import { TestWebhooks } from '@/TestWebhooks'; +import { TestWebhooks } from '@/webhooks/TestWebhooks'; import { OrchestrationService } from '@/services/orchestration.service'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { CommunityPackagesService } from '@/services/communityPackages.service'; diff --git a/packages/cli/src/ActiveWebhooks.ts b/packages/cli/src/webhooks/ActiveWebhooks.ts similarity index 91% rename from packages/cli/src/ActiveWebhooks.ts rename to packages/cli/src/webhooks/ActiveWebhooks.ts index 43136e75d384a..c18f949b88cca 100644 --- a/packages/cli/src/ActiveWebhooks.ts +++ b/packages/cli/src/webhooks/ActiveWebhooks.ts @@ -5,20 +5,25 @@ import type { INode, IWebhookData, IHttpRequestMethods } from 'n8n-workflow'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { - IResponseCallbackData, + IWebhookResponseCallbackData, IWebhookManager, WebhookAccessControlOptions, WebhookRequest, -} from '@/Interfaces'; +} from './webhook.types'; import { Logger } from '@/Logger'; import { NodeTypes } from '@/NodeTypes'; -import { WebhookService } from '@/services/webhook.service'; +import { WebhookService } from '@/webhooks/webhook.service'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service'; +/** + * Service for handling the execution of production webhooks, i.e. webhooks + * that belong to activated workflows and use the production URL + * (https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/#webhook-urls) + */ @Service() export class ActiveWebhooks implements IWebhookManager { constructor( @@ -57,7 +62,7 @@ export class ActiveWebhooks implements IWebhookManager { async executeWebhook( request: WebhookRequest, response: Response, - ): Promise { + ): Promise { const httpMethod = request.method; const path = request.params.path; diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/webhooks/TestWebhooks.ts similarity index 94% rename from packages/cli/src/TestWebhooks.ts rename to packages/cli/src/webhooks/TestWebhooks.ts index 827226ee546be..8cf1b4292989b 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/webhooks/TestWebhooks.ts @@ -8,26 +8,30 @@ import type { IRunData, } from 'n8n-workflow'; import type { - IResponseCallbackData, + IWebhookResponseCallbackData, IWebhookManager, - IWorkflowDb, WebhookAccessControlOptions, WebhookRequest, -} from '@/Interfaces'; +} from './webhook.types'; import { Push } from '@/push'; import { NodeTypes } from '@/NodeTypes'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import { TEST_WEBHOOK_TIMEOUT } from '@/constants'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WorkflowMissingIdError } from '@/errors/workflow-missing-id.error'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; import * as NodeExecuteFunctions from 'n8n-core'; -import { removeTrailingSlash } from './utils'; -import type { TestWebhookRegistration } from '@/services/test-webhook-registrations.service'; -import { TestWebhookRegistrationsService } from '@/services/test-webhook-registrations.service'; +import { removeTrailingSlash } from '@/utils'; +import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrations.service'; +import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; import { OrchestrationService } from '@/services/orchestration.service'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; +import type { IWorkflowDb } from '@/Interfaces'; +/** + * Service for handling the execution of webhooks of manual executions + * that use the [Test URL](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/#webhook-urls). + */ @Service() export class TestWebhooks implements IWebhookManager { constructor( @@ -46,7 +50,7 @@ export class TestWebhooks implements IWebhookManager { async executeWebhook( request: WebhookRequest, response: express.Response, - ): Promise { + ): Promise { const httpMethod = request.method; let path = removeTrailingSlash(request.params.path); @@ -117,7 +121,7 @@ export class TestWebhooks implements IWebhookManager { undefined, // executionId request, response, - (error: Error | null, data: IResponseCallbackData) => { + (error: Error | null, data: IWebhookResponseCallbackData) => { if (error !== null) reject(error); else resolve(data); }, diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/webhooks/WaitingWebhooks.ts similarity index 88% rename from packages/cli/src/WaitingWebhooks.ts rename to packages/cli/src/webhooks/WaitingWebhooks.ts index d795c948f142f..367635c45ec64 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/webhooks/WaitingWebhooks.ts @@ -2,21 +2,25 @@ import { NodeHelpers, Workflow } from 'n8n-workflow'; import { Service } from 'typedi'; import type express from 'express'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import { NodeTypes } from '@/NodeTypes'; import type { - IExecutionResponse, - IResponseCallbackData, + IWebhookResponseCallbackData, IWebhookManager, - IWorkflowDb, WaitingWebhookRequest, -} from '@/Interfaces'; +} from './webhook.types'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { Logger } from '@/Logger'; -import { ConflictError } from './errors/response-errors/conflict.error'; -import { NotFoundError } from './errors/response-errors/not-found.error'; - +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import type { IExecutionResponse, IWorkflowDb } from '@/Interfaces'; + +/** + * Service for handling the execution of webhooks of Wait nodes that use the + * [Resume On Webhook Call](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/#on-webhook-call) + * feature. + */ @Service() export class WaitingWebhooks implements IWebhookManager { protected includeForms = false; @@ -40,7 +44,7 @@ export class WaitingWebhooks implements IWebhookManager { async executeWebhook( req: WaitingWebhookRequest, res: express.Response, - ): Promise { + ): Promise { const { path: executionId, suffix } = req.params; this.logReceivedWebhook(req.method, executionId); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/webhooks/WebhookHelpers.ts similarity index 97% rename from packages/cli/src/WebhookHelpers.ts rename to packages/cli/src/webhooks/WebhookHelpers.ts index eafec28133326..8a98c74047985 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/webhooks/WebhookHelpers.ts @@ -43,27 +43,25 @@ import { } from 'n8n-workflow'; import type { - IExecutionDb, - IResponseCallbackData, + IWebhookResponseCallbackData, IWebhookManager, - IWorkflowDb, - IWorkflowExecutionDataProcess, WebhookCORSRequest, WebhookRequest, -} from '@/Interfaces'; +} from './webhook.types'; import * as ResponseHelper from '@/ResponseHelper'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import { WorkflowRunner } from '@/WorkflowRunner'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { ActiveExecutions } from '@/ActiveExecutions'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; -import { OwnershipService } from './services/ownership.service'; -import { parseBody } from './middlewares'; -import { Logger } from './Logger'; -import { NotFoundError } from './errors/response-errors/not-found.error'; -import { InternalServerError } from './errors/response-errors/internal-server.error'; -import { UnprocessableRequestError } from './errors/response-errors/unprocessable.error'; -import type { Project } from './databases/entities/Project'; +import { OwnershipService } from '@/services/ownership.service'; +import { parseBody } from '@/middlewares'; +import { Logger } from '@/Logger'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import type { Project } from '@/databases/entities/Project'; +import type { IExecutionDb, IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces'; export const WEBHOOK_METHODS: IHttpRequestMethods[] = [ 'DELETE', @@ -137,7 +135,7 @@ export const webhookRequestHandler = return ResponseHelper.sendSuccessResponse(res, {}, true, 204); } - let response; + let response: IWebhookResponseCallbackData; try { response = await webhookManager.executeWebhook(req, res); } catch (error) { @@ -228,7 +226,7 @@ export async function executeWebhook( executionId: string | undefined, req: WebhookRequest, res: express.Response, - responseCallback: (error: Error | null, data: IResponseCallbackData) => void, + responseCallback: (error: Error | null, data: IWebhookResponseCallbackData) => void, destinationNode?: string, ): Promise { // Get the nodeType to know which responseMode is set diff --git a/packages/cli/src/WebhookServer.ts b/packages/cli/src/webhooks/WebhookServer.ts similarity index 100% rename from packages/cli/src/WebhookServer.ts rename to packages/cli/src/webhooks/WebhookServer.ts diff --git a/packages/cli/src/__tests__/TestWebhooks.test.ts b/packages/cli/src/webhooks/__tests__/TestWebhooks.test.ts similarity index 93% rename from packages/cli/src/__tests__/TestWebhooks.test.ts rename to packages/cli/src/webhooks/__tests__/TestWebhooks.test.ts index 6c7ae555b3b52..deb6930b96e14 100644 --- a/packages/cli/src/__tests__/TestWebhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/TestWebhooks.test.ts @@ -1,20 +1,21 @@ import { mock } from 'jest-mock-extended'; -import { TestWebhooks } from '@/TestWebhooks'; +import { TestWebhooks } from '@/webhooks/TestWebhooks'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; import { v4 as uuid } from 'uuid'; import { generateNanoId } from '@/databases/utils/generators'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import type * as express from 'express'; -import type { IWorkflowDb, WebhookRequest } from '@/Interfaces'; +import type { IWorkflowDb } from '@/Interfaces'; import type { IWebhookData, IWorkflowExecuteAdditionalData, Workflow } from 'n8n-workflow'; import type { TestWebhookRegistrationsService, TestWebhookRegistration, -} from '@/services/test-webhook-registrations.service'; +} from '@/webhooks/test-webhook-registrations.service'; import * as AdditionalData from '@/WorkflowExecuteAdditionalData'; +import type { WebhookRequest } from '@/webhooks/webhook.types'; jest.mock('@/WorkflowExecuteAdditionalData'); diff --git a/packages/cli/src/__tests__/WebhookHelpers.test.ts b/packages/cli/src/webhooks/__tests__/WebhookHelpers.test.ts similarity index 97% rename from packages/cli/src/__tests__/WebhookHelpers.test.ts rename to packages/cli/src/webhooks/__tests__/WebhookHelpers.test.ts index 391d01b6fcfe4..9f86fbc549fcf 100644 --- a/packages/cli/src/__tests__/WebhookHelpers.test.ts +++ b/packages/cli/src/webhooks/__tests__/WebhookHelpers.test.ts @@ -3,8 +3,8 @@ import { mock } from 'jest-mock-extended'; import { randomString } from 'n8n-workflow'; import type { IHttpRequestMethods } from 'n8n-workflow'; -import type { IWebhookManager, WebhookCORSRequest, WebhookRequest } from '@/Interfaces'; -import { webhookRequestHandler } from '@/WebhookHelpers'; +import type { IWebhookManager, WebhookCORSRequest, WebhookRequest } from '@/webhooks/webhook.types'; +import { webhookRequestHandler } from '@/webhooks/WebhookHelpers'; describe('WebhookHelpers', () => { describe('webhookRequestHandler', () => { diff --git a/packages/cli/src/services/__tests__/test-webhook-registrations.service.test.ts b/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts similarity index 95% rename from packages/cli/src/services/__tests__/test-webhook-registrations.service.test.ts rename to packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts index c93540938c1bd..95502e3611e7c 100644 --- a/packages/cli/src/services/__tests__/test-webhook-registrations.service.test.ts +++ b/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts @@ -1,7 +1,7 @@ import type { CacheService } from '@/services/cache/cache.service'; import type { OrchestrationService } from '@/services/orchestration.service'; -import type { TestWebhookRegistration } from '@/services/test-webhook-registrations.service'; -import { TestWebhookRegistrationsService } from '@/services/test-webhook-registrations.service'; +import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrations.service'; +import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; import { mock } from 'jest-mock-extended'; describe('TestWebhookRegistrationsService', () => { diff --git a/packages/cli/src/__tests__/waiting-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts similarity index 90% rename from packages/cli/src/__tests__/waiting-webhooks.test.ts rename to packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts index 6748c7233566f..31f64eb198432 100644 --- a/packages/cli/src/__tests__/waiting-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts @@ -1,10 +1,11 @@ import { mock } from 'jest-mock-extended'; -import { WaitingWebhooks } from '@/WaitingWebhooks'; +import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { IExecutionResponse, WaitingWebhookRequest } from '@/Interfaces'; +import type { IExecutionResponse } from '@/Interfaces'; import type express from 'express'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import type { WaitingWebhookRequest } from '@/webhooks/webhook.types'; describe('WaitingWebhooks', () => { const executionRepository = mock(); diff --git a/packages/cli/src/services/__tests__/webhook.service.test.ts b/packages/cli/src/webhooks/__tests__/webhook.service.test.ts similarity index 99% rename from packages/cli/src/services/__tests__/webhook.service.test.ts rename to packages/cli/src/webhooks/__tests__/webhook.service.test.ts index 181bc60752956..5a8e19e84c809 100644 --- a/packages/cli/src/services/__tests__/webhook.service.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook.service.test.ts @@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid'; import config from '@/config'; import { WebhookRepository } from '@db/repositories/webhook.repository'; import { CacheService } from '@/services/cache/cache.service'; -import { WebhookService } from '@/services/webhook.service'; +import { WebhookService } from '@/webhooks/webhook.service'; import { WebhookEntity } from '@db/entities/WebhookEntity'; import { mockInstance } from '@test/mocking'; diff --git a/packages/cli/src/services/test-webhook-registrations.service.ts b/packages/cli/src/webhooks/test-webhook-registrations.service.ts similarity index 97% rename from packages/cli/src/services/test-webhook-registrations.service.ts rename to packages/cli/src/webhooks/test-webhook-registrations.service.ts index e2abae5605f98..94e7e7d826dbf 100644 --- a/packages/cli/src/services/test-webhook-registrations.service.ts +++ b/packages/cli/src/webhooks/test-webhook-registrations.service.ts @@ -3,7 +3,7 @@ import { CacheService } from '@/services/cache/cache.service'; import type { IWebhookData } from 'n8n-workflow'; import type { IWorkflowDb } from '@/Interfaces'; import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER } from '@/constants'; -import { OrchestrationService } from './orchestration.service'; +import { OrchestrationService } from '@/services/orchestration.service'; export type TestWebhookRegistration = { pushRef?: string; diff --git a/packages/cli/src/services/webhook.service.ts b/packages/cli/src/webhooks/webhook.service.ts similarity index 100% rename from packages/cli/src/services/webhook.service.ts rename to packages/cli/src/webhooks/webhook.service.ts diff --git a/packages/cli/src/webhooks/webhook.types.ts b/packages/cli/src/webhooks/webhook.types.ts new file mode 100644 index 0000000000000..7602ed1871bb0 --- /dev/null +++ b/packages/cli/src/webhooks/webhook.types.ts @@ -0,0 +1,37 @@ +import type { Request, Response } from 'express'; +import type { IDataObject, IHttpRequestMethods } from 'n8n-workflow'; + +export type WebhookCORSRequest = Request & { method: 'OPTIONS' }; + +export type WebhookRequest = Request<{ path: string }> & { + method: IHttpRequestMethods; + params: Record; +}; + +export type WaitingWebhookRequest = WebhookRequest & { + params: WebhookRequest['path'] & { suffix?: string }; +}; + +export interface WebhookAccessControlOptions { + allowedOrigins?: string; +} + +export interface IWebhookManager { + /** Gets all request methods associated with a webhook path*/ + getWebhookMethods?: (path: string) => Promise; + + /** Find the CORS options matching a path and method */ + findAccessControlOptions?: ( + path: string, + httpMethod: IHttpRequestMethods, + ) => Promise; + + executeWebhook(req: WebhookRequest, res: Response): Promise; +} + +export interface IWebhookResponseCallbackData { + data?: IDataObject | IDataObject[]; + headers?: object; + noWebhookResponse?: boolean; + responseCode?: number; +} diff --git a/packages/cli/src/workflows/workflowExecution.service.ts b/packages/cli/src/workflows/workflowExecution.service.ts index 8ebe7aed0c142..9ddce37d2f207 100644 --- a/packages/cli/src/workflows/workflowExecution.service.ts +++ b/packages/cli/src/workflows/workflowExecution.service.ts @@ -30,7 +30,7 @@ import type { import { NodeTypes } from '@/NodeTypes'; import { WorkflowRunner } from '@/WorkflowRunner'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; -import { TestWebhooks } from '@/TestWebhooks'; +import { TestWebhooks } from '@/webhooks/TestWebhooks'; import { Logger } from '@/Logger'; import type { Project } from '@/databases/entities/Project'; import { GlobalConfig } from '@n8n/config'; diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index 03ce58e7ef6bc..de586b643a468 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -8,8 +8,8 @@ import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; import { ExternalHooks } from '@/ExternalHooks'; import { Push } from '@/push'; import { SecretsHelper } from '@/SecretsHelpers'; -import { WebhookService } from '@/services/webhook.service'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import { WebhookService } from '@/webhooks/webhook.service'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import * as AdditionalData from '@/WorkflowExecuteAdditionalData'; import type { WebhookEntity } from '@db/entities/WebhookEntity'; import { NodeTypes } from '@/NodeTypes'; diff --git a/packages/cli/test/integration/webhooks.test.ts b/packages/cli/test/integration/webhooks.test.ts index 9bd1977ed53d2..280edb370d286 100644 --- a/packages/cli/test/integration/webhooks.test.ts +++ b/packages/cli/test/integration/webhooks.test.ts @@ -3,13 +3,13 @@ import { agent as testAgent } from 'supertest'; import { mock } from 'jest-mock-extended'; import { AbstractServer } from '@/AbstractServer'; -import { ActiveWebhooks } from '@/ActiveWebhooks'; +import { ActiveWebhooks } from '@/webhooks/ActiveWebhooks'; import { ExternalHooks } from '@/ExternalHooks'; import { InternalHooks } from '@/InternalHooks'; -import { TestWebhooks } from '@/TestWebhooks'; -import { WaitingWebhooks } from '@/WaitingWebhooks'; +import { TestWebhooks } from '@/webhooks/TestWebhooks'; +import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; import { WaitingForms } from '@/WaitingForms'; -import type { IResponseCallbackData } from '@/Interfaces'; +import type { IWebhookResponseCallbackData } from '@/webhooks/webhook.types'; import { mockInstance } from '@test/mocking'; import { GlobalConfig } from '@n8n/config'; @@ -80,7 +80,7 @@ describe('WebhookServer', () => { } const mockResponse = (data = {}, headers = {}, status = 200) => { - const response = mock(); + const response = mock(); response.responseCode = status; response.data = data; response.headers = headers; From c9b3d34a5416c107434eb6f9df6a3cbd6d2e657c Mon Sep 17 00:00:00 2001 From: Oz Weiss Date: Wed, 7 Aug 2024 12:17:01 +0300 Subject: [PATCH 07/19] feat(Calendly Trigger Node): Update event names (no-changelog) (#10129) --- packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts b/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts index 40a10cab92cfa..e272caee9360e 100644 --- a/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts +++ b/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts @@ -95,12 +95,12 @@ export class CalendlyTrigger implements INodeType { type: 'multiOptions', options: [ { - name: 'invitee.created', + name: 'Event Created', value: 'invitee.created', description: 'Receive notifications when a new Calendly event is created', }, { - name: 'invitee.canceled', + name: 'Event Canceled', value: 'invitee.canceled', description: 'Receive notifications when a Calendly event is canceled', }, From 15f10ec325cb5eda0f952bed3a5f171dd91bc639 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 7 Aug 2024 10:17:29 +0100 Subject: [PATCH 08/19] feat(Lemlist Trigger Node): Update Trigger events (#10311) --- .../nodes/Lemlist/GenericFunctions.ts | 49 ++++++++++++++++--- .../nodes/Lemlist/LemlistTrigger.node.ts | 2 +- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/nodes-base/nodes/Lemlist/GenericFunctions.ts b/packages/nodes-base/nodes/Lemlist/GenericFunctions.ts index 8a587a939044f..e95bebe507cde 100644 --- a/packages/nodes-base/nodes/Lemlist/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Lemlist/GenericFunctions.ts @@ -71,16 +71,53 @@ export async function lemlistApiRequestAllItems( export function getEvents() { const events = [ '*', - 'emailsBounced', - 'emailsClicked', - 'emailsFailed', - 'emailsInterested', - 'emailsNotInterested', + 'contacted', + 'hooked', + 'attracted', + 'warmed', + 'interested', + 'skipped', + 'notInterested', + 'emailsSent', 'emailsOpened', + 'emailsClicked', 'emailsReplied', + 'emailsBounced', 'emailsSendFailed', - 'emailsSent', + 'emailsFailed', 'emailsUnsubscribed', + 'emailsInterested', + 'emailsNotInterested', + 'opportunitiesDone', + 'aircallCreated', + 'aircallEnded', + 'aircallDone', + 'aircallInterested', + 'aircallNotInterested', + 'apiDone', + 'apiInterested', + 'apiNotInterested', + 'apiFailed', + 'linkedinVisitDone', + 'linkedinVisitFailed', + 'linkedinInviteDone', + 'linkedinInviteFailed', + 'linkedinInviteAccepted', + 'linkedinReplied', + 'linkedinSent', + 'linkedinVoiceNoteDone', + 'linkedinVoiceNoteFailed', + 'linkedinInterested', + 'linkedinNotInterested', + 'linkedinSendFailed', + 'manualInterested', + 'manualNotInterested', + 'paused', + 'resumed', + 'customDomainErrors', + 'connectionIssue', + 'sendLimitReached', + 'lemwarmPaused', ]; return events.map((event: string) => ({ diff --git a/packages/nodes-base/nodes/Lemlist/LemlistTrigger.node.ts b/packages/nodes-base/nodes/Lemlist/LemlistTrigger.node.ts index a9306c21c5604..bd1a20cf8fdea 100644 --- a/packages/nodes-base/nodes/Lemlist/LemlistTrigger.node.ts +++ b/packages/nodes-base/nodes/Lemlist/LemlistTrigger.node.ts @@ -55,7 +55,7 @@ export class LemlistTrigger implements INodeType { default: {}, options: [ { - displayName: 'Campaing Name or ID', + displayName: 'Campaign Name or ID', name: 'campaignId', type: 'options', typeOptions: { From 6d8323fadea8af04483eb1a873df0cf3ccc2a891 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 7 Aug 2024 10:18:05 +0100 Subject: [PATCH 09/19] feat(Webflow Node): Update to use the v2 API (#9996) --- .../WebflowOAuth2Api.credentials.ts | 9 +- .../nodes/Webflow/GenericFunctions.ts | 75 ++++- .../nodes/Webflow/{ => V1}/ItemDescription.ts | 0 .../nodes/Webflow/V1/WebflowTriggerV1.node.ts | 209 ++++++++++++ .../nodes/Webflow/V1/WebflowV1.node.ts | 258 ++++++++++++++ .../nodes/Webflow/V2/WebflowTriggerV2.node.ts | 179 ++++++++++ .../nodes/Webflow/V2/WebflowV2.node.ts | 32 ++ .../Webflow/V2/actions/Item/Item.resource.ts | 56 ++++ .../V2/actions/Item/create.operation.ts | 139 ++++++++ .../V2/actions/Item/delete.operation.ts | 94 ++++++ .../Webflow/V2/actions/Item/get.operation.ts | 88 +++++ .../V2/actions/Item/getAll.operation.ts | 118 +++++++ .../V2/actions/Item/update.operation.ts | 148 +++++++++ .../nodes/Webflow/V2/actions/node.type.ts | 7 + .../nodes/Webflow/V2/actions/router.ts | 35 ++ .../Webflow/V2/actions/versionDescription.ts | 41 +++ .../nodes-base/nodes/Webflow/Webflow.node.ts | 314 ++---------------- .../nodes/Webflow/WebflowTrigger.node.ts | 289 ++-------------- 18 files changed, 1518 insertions(+), 573 deletions(-) rename packages/nodes-base/nodes/Webflow/{ => V1}/ItemDescription.ts (100%) create mode 100644 packages/nodes-base/nodes/Webflow/V1/WebflowTriggerV1.node.ts create mode 100644 packages/nodes-base/nodes/Webflow/V1/WebflowV1.node.ts create mode 100644 packages/nodes-base/nodes/Webflow/V2/WebflowTriggerV2.node.ts create mode 100644 packages/nodes-base/nodes/Webflow/V2/WebflowV2.node.ts create mode 100644 packages/nodes-base/nodes/Webflow/V2/actions/Item/Item.resource.ts create mode 100644 packages/nodes-base/nodes/Webflow/V2/actions/Item/create.operation.ts create mode 100644 packages/nodes-base/nodes/Webflow/V2/actions/Item/delete.operation.ts create mode 100644 packages/nodes-base/nodes/Webflow/V2/actions/Item/get.operation.ts create mode 100644 packages/nodes-base/nodes/Webflow/V2/actions/Item/getAll.operation.ts create mode 100644 packages/nodes-base/nodes/Webflow/V2/actions/Item/update.operation.ts create mode 100644 packages/nodes-base/nodes/Webflow/V2/actions/node.type.ts create mode 100644 packages/nodes-base/nodes/Webflow/V2/actions/router.ts create mode 100644 packages/nodes-base/nodes/Webflow/V2/actions/versionDescription.ts diff --git a/packages/nodes-base/credentials/WebflowOAuth2Api.credentials.ts b/packages/nodes-base/credentials/WebflowOAuth2Api.credentials.ts index 09c5dd63d3d05..8c754995f8401 100644 --- a/packages/nodes-base/credentials/WebflowOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/WebflowOAuth2Api.credentials.ts @@ -16,6 +16,13 @@ export class WebflowOAuth2Api implements ICredentialType { type: 'hidden', default: 'authorizationCode', }, + { + displayName: 'Legacy', + name: 'legacy', + type: 'boolean', + default: true, + description: 'If the legacy API should be used', + }, { displayName: 'Authorization URL', name: 'authUrl', @@ -34,7 +41,7 @@ export class WebflowOAuth2Api implements ICredentialType { displayName: 'Scope', name: 'scope', type: 'hidden', - default: '', + default: '={{$self["legacy"] ? "" : "cms:read cms:write sites:read"}}', }, { displayName: 'Auth URI Query Parameters', diff --git a/packages/nodes-base/nodes/Webflow/GenericFunctions.ts b/packages/nodes-base/nodes/Webflow/GenericFunctions.ts index 4cbf8c277520b..49d02bcf04da6 100644 --- a/packages/nodes-base/nodes/Webflow/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Webflow/GenericFunctions.ts @@ -6,6 +6,7 @@ import type { IWebhookFunctions, IHttpRequestMethods, IRequestOptions, + INodePropertyOptions, } from 'n8n-workflow'; export async function webflowApiRequest( @@ -17,21 +18,9 @@ export async function webflowApiRequest( uri?: string, option: IDataObject = {}, ) { - const authenticationMethod = this.getNodeParameter('authentication', 0, 'accessToken'); - let credentialsType = ''; - - if (authenticationMethod === 'accessToken') { - credentialsType = 'webflowApi'; - } - - if (authenticationMethod === 'oAuth2') { - credentialsType = 'webflowOAuth2Api'; - } + let credentialsType = 'webflowOAuth2Api'; let options: IRequestOptions = { - headers: { - 'accept-version': '1.0.0', - }, method, qs, body, @@ -40,6 +29,19 @@ export async function webflowApiRequest( }; options = Object.assign({}, options, option); + // Keep support for v1 node + if (this.getNode().typeVersion === 1) { + console.log('v1'); + const authenticationMethod = this.getNodeParameter('authentication', 0, 'accessToken'); + if (authenticationMethod === 'accessToken') { + credentialsType = 'webflowApi'; + } + options.headers = { 'accept-version': '1.0.0' }; + } else { + options.resolveWithFullResponse = true; + options.uri = `https://api.webflow.com/v2${resource}`; + } + if (Object.keys(options.qs as IDataObject).length === 0) { delete options.qs; } @@ -74,3 +76,50 @@ export async function webflowApiRequestAllItems( return returnData; } +// Load Options +export async function getSites(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const response = await webflowApiRequest.call(this, 'GET', '/sites'); + + console.log(response); + + const sites = response.body?.sites || response; + + for (const site of sites) { + returnData.push({ + name: site.displayName || site.name, + value: site.id || site._id, + }); + } + return returnData; +} +export async function getCollections(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const siteId = this.getCurrentNodeParameter('siteId'); + const response = await webflowApiRequest.call(this, 'GET', `/sites/${siteId}/collections`); + + const collections = response.body?.collections || response; + + for (const collection of collections) { + returnData.push({ + name: collection.displayName || collection.name, + value: collection.id || collection._id, + }); + } + return returnData; +} +export async function getFields(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const collectionId = this.getCurrentNodeParameter('collectionId'); + const response = await webflowApiRequest.call(this, 'GET', `/collections/${collectionId}`); + + const fields = response.body?.fields || response; + + for (const field of fields) { + returnData.push({ + name: `${field.displayName || field.name} (${field.type}) ${field.isRequired || field.required ? ' (required)' : ''}`, + value: field.slug, + }); + } + return returnData; +} diff --git a/packages/nodes-base/nodes/Webflow/ItemDescription.ts b/packages/nodes-base/nodes/Webflow/V1/ItemDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Webflow/ItemDescription.ts rename to packages/nodes-base/nodes/Webflow/V1/ItemDescription.ts diff --git a/packages/nodes-base/nodes/Webflow/V1/WebflowTriggerV1.node.ts b/packages/nodes-base/nodes/Webflow/V1/WebflowTriggerV1.node.ts new file mode 100644 index 0000000000000..9163fd495e5c3 --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V1/WebflowTriggerV1.node.ts @@ -0,0 +1,209 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { + IHookFunctions, + IWebhookFunctions, + IDataObject, + INodeType, + INodeTypeDescription, + IWebhookResponseData, + INodeTypeBaseDescription, +} from 'n8n-workflow'; + +import { getSites, webflowApiRequest } from '../GenericFunctions'; + +export class WebflowTriggerV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + displayName: 'Webflow Trigger', + name: 'webflowTrigger', + icon: 'file:webflow.svg', + group: ['trigger'], + version: 1, + description: 'Handle Webflow events via webhooks', + defaults: { + name: 'Webflow Trigger', + }, + // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'webflowApi', + required: true, + displayOptions: { + show: { + authentication: ['accessToken'], + }, + }, + }, + { + name: 'webflowOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + }, + { + displayName: 'Site Name or ID', + name: 'site', + type: 'options', + required: true, + default: '', + typeOptions: { + loadOptionsMethod: 'getSites', + }, + description: + 'Site that will trigger the events. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Event', + name: 'event', + type: 'options', + required: true, + options: [ + { + name: 'Collection Item Created', + value: 'collection_item_created', + }, + { + name: 'Collection Item Deleted', + value: 'collection_item_deleted', + }, + { + name: 'Collection Item Updated', + value: 'collection_item_changed', + }, + { + name: 'Ecomm Inventory Changed', + value: 'ecomm_inventory_changed', + }, + { + name: 'Ecomm New Order', + value: 'ecomm_new_order', + }, + { + name: 'Ecomm Order Changed', + value: 'ecomm_order_changed', + }, + { + name: 'Form Submission', + value: 'form_submission', + }, + { + name: 'Site Publish', + value: 'site_publish', + }, + ], + default: 'form_submission', + }, + ], + }; + } + + methods = { + loadOptions: { + getSites, + }, + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const siteId = this.getNodeParameter('site') as string; + + const event = this.getNodeParameter('event') as string; + const registeredWebhooks = await webflowApiRequest.call( + this, + 'GET', + `/sites/${siteId}/webhooks`, + ); + + const webhooks = registeredWebhooks.body?.webhooks || registeredWebhooks; + + for (const webhook of webhooks) { + if (webhook.url === webhookUrl && webhook.triggerType === event) { + webhookData.webhookId = webhook._id; + return true; + } + } + + return false; + }, + + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + const siteId = this.getNodeParameter('site') as string; + const event = this.getNodeParameter('event') as string; + const endpoint = `/sites/${siteId}/webhooks`; + const body: IDataObject = { + site_id: siteId, + triggerType: event, + url: webhookUrl, + }; + + const response = await webflowApiRequest.call(this, 'POST', endpoint, body); + const _id = response.body?._id || response._id; + webhookData.webhookId = _id; + return true; + }, + async delete(this: IHookFunctions): Promise { + let responseData; + const webhookData = this.getWorkflowStaticData('node'); + const siteId = this.getNodeParameter('site') as string; + const endpoint = `/sites/${siteId}/webhooks/${webhookData.webhookId}`; + try { + responseData = await webflowApiRequest.call(this, 'DELETE', endpoint); + } catch (error) { + return false; + } + const deleted = responseData.body?.deleted || responseData.deleted; + if (!deleted) { + return false; + } + delete webhookData.webhookId; + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const req = this.getRequestObject(); + return { + workflowData: [this.helpers.returnJsonArray(req.body as IDataObject[])], + }; + } +} diff --git a/packages/nodes-base/nodes/Webflow/V1/WebflowV1.node.ts b/packages/nodes-base/nodes/Webflow/V1/WebflowV1.node.ts new file mode 100644 index 0000000000000..d4392c2174839 --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V1/WebflowV1.node.ts @@ -0,0 +1,258 @@ +import type { + IExecuteFunctions, + IDataObject, + INodeTypeBaseDescription, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + webflowApiRequest, + webflowApiRequestAllItems, + getSites, + getCollections, + getFields, +} from '../GenericFunctions'; + +import { itemFields, itemOperations } from './ItemDescription'; + +export class WebflowV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + version: 1, + description: 'Consume the Webflow API', + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + defaults: { + name: 'Webflow', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'webflowApi', + required: true, + displayOptions: { + show: { + authentication: ['accessToken'], + }, + }, + }, + { + name: 'webflowOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Item', + value: 'item', + }, + ], + default: 'item', + }, + ...itemOperations, + ...itemFields, + ], + }; + } + + methods = { + loadOptions: { + getSites, + getCollections, + getFields, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + let responseData; + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + if (resource === 'item') { + // ********************************************************************* + // item + // ********************************************************************* + + // https://developers.webflow.com/#item-model + + if (operation === 'create') { + // ---------------------------------- + // item: create + // ---------------------------------- + + // https://developers.webflow.com/#create-new-collection-item + + const collectionId = this.getNodeParameter('collectionId', i) as string; + + const properties = this.getNodeParameter( + 'fieldsUi.fieldValues', + i, + [], + ) as IDataObject[]; + + const live = this.getNodeParameter('live', i) as boolean; + + const fields = {} as IDataObject; + + properties.forEach((data) => (fields[data.fieldId as string] = data.fieldValue)); + + const body: IDataObject = { + fields, + }; + + responseData = await webflowApiRequest.call( + this, + 'POST', + `/collections/${collectionId}/items`, + body, + { live }, + ); + } else if (operation === 'delete') { + // ---------------------------------- + // item: delete + // ---------------------------------- + + // https://developers.webflow.com/#remove-collection-item + + const collectionId = this.getNodeParameter('collectionId', i) as string; + const itemId = this.getNodeParameter('itemId', i) as string; + responseData = await webflowApiRequest.call( + this, + 'DELETE', + `/collections/${collectionId}/items/${itemId}`, + ); + } else if (operation === 'get') { + // ---------------------------------- + // item: get + // ---------------------------------- + + // https://developers.webflow.com/#get-single-item + + const collectionId = this.getNodeParameter('collectionId', i) as string; + const itemId = this.getNodeParameter('itemId', i) as string; + responseData = await webflowApiRequest.call( + this, + 'GET', + `/collections/${collectionId}/items/${itemId}`, + ); + responseData = responseData.items; + } else if (operation === 'getAll') { + // ---------------------------------- + // item: getAll + // ---------------------------------- + + // https://developers.webflow.com/#get-all-items-for-a-collection + + const returnAll = this.getNodeParameter('returnAll', 0); + const collectionId = this.getNodeParameter('collectionId', i) as string; + const qs: IDataObject = {}; + + if (returnAll) { + responseData = await webflowApiRequestAllItems.call( + this, + 'GET', + `/collections/${collectionId}/items`, + {}, + qs, + ); + } else { + qs.limit = this.getNodeParameter('limit', 0); + responseData = await webflowApiRequest.call( + this, + 'GET', + `/collections/${collectionId}/items`, + {}, + qs, + ); + responseData = responseData.items; + } + } else if (operation === 'update') { + // ---------------------------------- + // item: update + // ---------------------------------- + + // https://developers.webflow.com/#update-collection-item + + const collectionId = this.getNodeParameter('collectionId', i) as string; + + const itemId = this.getNodeParameter('itemId', i) as string; + + const properties = this.getNodeParameter( + 'fieldsUi.fieldValues', + i, + [], + ) as IDataObject[]; + + const live = this.getNodeParameter('live', i) as boolean; + + const fields = {} as IDataObject; + + properties.forEach((data) => (fields[data.fieldId as string] = data.fieldValue)); + + const body: IDataObject = { + fields, + }; + + responseData = await webflowApiRequest.call( + this, + 'PUT', + `/collections/${collectionId}/items/${itemId}`, + body, + { live }, + ); + } + } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { error: error.message } }); + continue; + } + throw error; + } + } + + return [returnData]; + } +} diff --git a/packages/nodes-base/nodes/Webflow/V2/WebflowTriggerV2.node.ts b/packages/nodes-base/nodes/Webflow/V2/WebflowTriggerV2.node.ts new file mode 100644 index 0000000000000..560f7390abf8d --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V2/WebflowTriggerV2.node.ts @@ -0,0 +1,179 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { + IHookFunctions, + IDataObject, + INodeType, + INodeTypeDescription, + INodeTypeBaseDescription, + IWebhookFunctions, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { getSites, webflowApiRequest } from '../GenericFunctions'; + +export class WebflowTriggerV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + displayName: 'Webflow Trigger', + name: 'webflowTrigger', + icon: 'file:webflow.svg', + group: ['trigger'], + version: 2, + description: 'Handle Webflow events via webhooks', + defaults: { + name: 'Webflow Trigger', + }, + // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'webflowOAuth2Api', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Site Name or ID', + name: 'site', + type: 'options', + required: true, + default: '', + typeOptions: { + loadOptionsMethod: 'getSites', + }, + description: + 'Site that will trigger the events. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Event', + name: 'event', + type: 'options', + required: true, + options: [ + { + name: 'Collection Item Created', + value: 'collection_item_created', + }, + { + name: 'Collection Item Deleted', + value: 'collection_item_deleted', + }, + { + name: 'Collection Item Updated', + value: 'collection_item_changed', + }, + { + name: 'Ecomm Inventory Changed', + value: 'ecomm_inventory_changed', + }, + { + name: 'Ecomm New Order', + value: 'ecomm_new_order', + }, + { + name: 'Ecomm Order Changed', + value: 'ecomm_order_changed', + }, + { + name: 'Form Submission', + value: 'form_submission', + }, + { + name: 'Site Publish', + value: 'site_publish', + }, + ], + default: 'form_submission', + }, + ], + }; + } + + methods = { + loadOptions: { + getSites, + }, + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const siteId = this.getNodeParameter('site') as string; + + const event = this.getNodeParameter('event') as string; + const registeredWebhooks = await webflowApiRequest.call( + this, + 'GET', + `/sites/${siteId}/webhooks`, + ); + + const webhooks = registeredWebhooks.body?.webhooks || registeredWebhooks; + + for (const webhook of webhooks) { + if (webhook.url === webhookUrl && webhook.triggerType === event) { + webhookData.webhookId = webhook._id; + return true; + } + } + + return false; + }, + + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + const siteId = this.getNodeParameter('site') as string; + const event = this.getNodeParameter('event') as string; + const endpoint = `/sites/${siteId}/webhooks`; + const body: IDataObject = { + site_id: siteId, + triggerType: event, + url: webhookUrl, + }; + + const response = await webflowApiRequest.call(this, 'POST', endpoint, body); + const _id = response.body?._id || response._id; + webhookData.webhookId = _id; + return true; + }, + async delete(this: IHookFunctions): Promise { + let responseData; + const webhookData = this.getWorkflowStaticData('node'); + const siteId = this.getNodeParameter('site') as string; + const endpoint = `/sites/${siteId}/webhooks/${webhookData.webhookId}`; + try { + responseData = await webflowApiRequest.call(this, 'DELETE', endpoint); + } catch (error) { + return false; + } + const deleted = responseData.body?.deleted || responseData.deleted; + if (!deleted) { + return false; + } + delete webhookData.webhookId; + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const req = this.getRequestObject(); + return { + workflowData: [this.helpers.returnJsonArray(req.body as IDataObject[])], + }; + } +} diff --git a/packages/nodes-base/nodes/Webflow/V2/WebflowV2.node.ts b/packages/nodes-base/nodes/Webflow/V2/WebflowV2.node.ts new file mode 100644 index 0000000000000..ad17ff9e05e13 --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V2/WebflowV2.node.ts @@ -0,0 +1,32 @@ +import type { + IExecuteFunctions, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; +import { getSites, getCollections, getFields } from '../GenericFunctions'; +import { versionDescription } from './actions/versionDescription'; +import { router } from './actions/router'; + +export class WebflowV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + loadOptions: { + getSites, + getCollections, + getFields, + }, + }; + + async execute(this: IExecuteFunctions) { + return await router.call(this); + } +} diff --git a/packages/nodes-base/nodes/Webflow/V2/actions/Item/Item.resource.ts b/packages/nodes-base/nodes/Webflow/V2/actions/Item/Item.resource.ts new file mode 100644 index 0000000000000..420d2d8490327 --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V2/actions/Item/Item.resource.ts @@ -0,0 +1,56 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as create from './create.operation'; +import * as deleteItem from './delete.operation'; +import * as get from './get.operation'; +import * as getAll from './getAll.operation'; +import * as update from './update.operation'; + +export { create, deleteItem, get, getAll, update }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'get', + options: [ + { + name: 'Create', + value: 'create', + action: 'Create an item', + }, + { + name: 'Delete', + value: 'deleteItem', + action: 'Delete an item', + }, + { + name: 'Get', + value: 'get', + action: 'Get an item', + }, + { + name: 'Get Many', + value: 'getAll', + action: 'Get many items', + }, + { + name: 'Update', + value: 'update', + action: 'Update an item', + }, + ], + displayOptions: { + show: { + resource: ['item'], + }, + }, + }, + ...create.description, + ...deleteItem.description, + ...get.description, + ...getAll.description, + ...update.description, +]; diff --git a/packages/nodes-base/nodes/Webflow/V2/actions/Item/create.operation.ts b/packages/nodes-base/nodes/Webflow/V2/actions/Item/create.operation.ts new file mode 100644 index 0000000000000..8eeeb9cb6646a --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V2/actions/Item/create.operation.ts @@ -0,0 +1,139 @@ +import type { + IDataObject, + INodeExecutionData, + INodeProperties, + IExecuteFunctions, +} from 'n8n-workflow'; + +import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities'; +import { webflowApiRequest } from '../../../GenericFunctions'; + +const properties: INodeProperties[] = [ + { + displayName: 'Site Name or ID', + name: 'siteId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + description: + 'ID of the site containing the collection whose items to add to. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Collection Name or ID', + name: 'collectionId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getCollections', + loadOptionsDependsOn: ['siteId'], + }, + default: '', + description: + 'ID of the collection to add an item to. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Live', + name: 'live', + type: 'boolean', + required: true, + default: false, + description: 'Whether the item should be published on the live site', + }, + { + displayName: 'Fields', + name: 'fieldsUi', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Field', + name: 'fieldValues', + values: [ + { + displayName: 'Field Name or ID', + name: 'fieldId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getFields', + loadOptionsDependsOn: ['collectionId'], + }, + default: '', + description: + 'Field to set for the item to create. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + description: 'Value to set for the item to create', + }, + ], + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['item'], + operation: ['create'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + items: INodeExecutionData[], +): Promise { + const returnData: INodeExecutionData[] = []; + let responseData; + for (let i = 0; i < items.length; i++) { + try { + const collectionId = this.getNodeParameter('collectionId', i) as string; + + const uiFields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as IDataObject[]; + + const live = this.getNodeParameter('live', i) as boolean; + + const fieldData = {} as IDataObject; + + uiFields.forEach((data) => (fieldData[data.fieldId as string] = data.fieldValue)); + + const body: IDataObject = { + fieldData, + }; + + responseData = await webflowApiRequest.call( + this, + 'POST', + `/collections/${collectionId}/items`, + body, + { live }, + ); + + const executionData = this.helpers.constructExecutionMetaData( + wrapData(responseData.body as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { message: error.message, error } }); + continue; + } + throw error; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Webflow/V2/actions/Item/delete.operation.ts b/packages/nodes-base/nodes/Webflow/V2/actions/Item/delete.operation.ts new file mode 100644 index 0000000000000..632a8e6e93d43 --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V2/actions/Item/delete.operation.ts @@ -0,0 +1,94 @@ +import type { + IDataObject, + INodeExecutionData, + INodeProperties, + IExecuteFunctions, +} from 'n8n-workflow'; + +import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities'; +import { webflowApiRequest } from '../../../GenericFunctions'; + +const properties: INodeProperties[] = [ + { + displayName: 'Site Name or ID', + name: 'siteId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + description: + 'ID of the site containing the collection whose items to operate on. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Collection Name or ID', + name: 'collectionId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getCollections', + loadOptionsDependsOn: ['siteId'], + }, + default: '', + description: + 'ID of the collection whose items to operate on. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Item ID', + name: 'itemId', + type: 'string', + required: true, + default: '', + description: 'ID of the item to operate on', + }, +]; + +const displayOptions = { + show: { + resource: ['item'], + operation: ['deleteItem'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + items: INodeExecutionData[], +): Promise { + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + const collectionId = this.getNodeParameter('collectionId', i) as string; + const itemId = this.getNodeParameter('itemId', i) as string; + let responseData = await webflowApiRequest.call( + this, + 'DELETE', + `/collections/${collectionId}/items/${itemId}`, + ); + + if (responseData.statusCode === 204) { + responseData = { success: true }; + } else { + responseData = { success: false }; + } + + const executionData = this.helpers.constructExecutionMetaData( + wrapData(responseData as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { message: error.message, error } }); + continue; + } + throw error; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Webflow/V2/actions/Item/get.operation.ts b/packages/nodes-base/nodes/Webflow/V2/actions/Item/get.operation.ts new file mode 100644 index 0000000000000..c02b3fc26e7ff --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V2/actions/Item/get.operation.ts @@ -0,0 +1,88 @@ +import type { + IDataObject, + INodeExecutionData, + INodeProperties, + IExecuteFunctions, +} from 'n8n-workflow'; + +import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities'; +import { webflowApiRequest } from '../../../GenericFunctions'; + +const properties: INodeProperties[] = [ + { + displayName: 'Site Name or ID', + name: 'siteId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + description: + 'ID of the site containing the collection whose items to operate on. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Collection Name or ID', + name: 'collectionId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getCollections', + loadOptionsDependsOn: ['siteId'], + }, + default: '', + description: + 'ID of the collection whose items to operate on. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Item ID', + name: 'itemId', + type: 'string', + required: true, + default: '', + description: 'ID of the item to operate on', + }, +]; + +const displayOptions = { + show: { + resource: ['item'], + operation: ['get'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + items: INodeExecutionData[], +): Promise { + const returnData: INodeExecutionData[] = []; + let responseData; + for (let i = 0; i < items.length; i++) { + try { + const collectionId = this.getNodeParameter('collectionId', i) as string; + const itemId = this.getNodeParameter('itemId', i) as string; + responseData = await webflowApiRequest.call( + this, + 'GET', + `/collections/${collectionId}/items/${itemId}`, + ); + + const executionData = this.helpers.constructExecutionMetaData( + wrapData(responseData.body as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { message: error.message, error } }); + continue; + } + throw error; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Webflow/V2/actions/Item/getAll.operation.ts b/packages/nodes-base/nodes/Webflow/V2/actions/Item/getAll.operation.ts new file mode 100644 index 0000000000000..6f9bd0f5b1949 --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V2/actions/Item/getAll.operation.ts @@ -0,0 +1,118 @@ +import type { + IDataObject, + INodeExecutionData, + INodeProperties, + IExecuteFunctions, +} from 'n8n-workflow'; + +import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities'; +import { webflowApiRequest, webflowApiRequestAllItems } from '../../../GenericFunctions'; + +const properties: INodeProperties[] = [ + { + displayName: 'Site Name or ID', + name: 'siteId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + description: + 'ID of the site containing the collection whose items to operate on. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Collection Name or ID', + name: 'collectionId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getCollections', + loadOptionsDependsOn: ['siteId'], + }, + default: '', + description: + 'ID of the collection whose items to operate on. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + typeOptions: { + minValue: 1, + maxValue: 100, + }, + displayOptions: { + show: { + returnAll: [false], + }, + }, + default: 100, + description: 'Max number of results to return', + }, +]; + +const displayOptions = { + show: { + resource: ['item'], + operation: ['getAll'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + items: INodeExecutionData[], +): Promise { + const returnData: INodeExecutionData[] = []; + let responseData; + for (let i = 0; i < items.length; i++) { + try { + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const collectionId = this.getNodeParameter('collectionId', i) as string; + const qs: IDataObject = {}; + + if (returnAll) { + responseData = await webflowApiRequestAllItems.call( + this, + 'GET', + `/collections/${collectionId}/items`, + {}, + ); + } else { + qs.limit = this.getNodeParameter('limit', i); + responseData = await webflowApiRequest.call( + this, + 'GET', + `/collections/${collectionId}/items`, + {}, + qs, + ); + responseData = responseData.body.items; + } + + const executionData = this.helpers.constructExecutionMetaData( + wrapData(responseData as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { message: error.message, error } }); + continue; + } + throw error; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Webflow/V2/actions/Item/update.operation.ts b/packages/nodes-base/nodes/Webflow/V2/actions/Item/update.operation.ts new file mode 100644 index 0000000000000..e4f94ad17d83f --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V2/actions/Item/update.operation.ts @@ -0,0 +1,148 @@ +import type { + IDataObject, + INodeExecutionData, + INodeProperties, + IExecuteFunctions, +} from 'n8n-workflow'; + +import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities'; +import { webflowApiRequest } from '../../../GenericFunctions'; + +const properties: INodeProperties[] = [ + { + displayName: 'Site Name or ID', + name: 'siteId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + description: + 'ID of the site containing the collection whose items to add to. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Collection Name or ID', + name: 'collectionId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getCollections', + loadOptionsDependsOn: ['siteId'], + }, + default: '', + description: + 'ID of the collection to add an item to. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Item ID', + name: 'itemId', + type: 'string', + required: true, + default: '', + description: 'ID of the item to update', + }, + { + displayName: 'Live', + name: 'live', + type: 'boolean', + required: true, + default: false, + description: 'Whether the item should be published on the live site', + }, + { + displayName: 'Fields', + name: 'fieldsUi', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Field', + name: 'fieldValues', + values: [ + { + displayName: 'Field Name or ID', + name: 'fieldId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getFields', + loadOptionsDependsOn: ['collectionId'], + }, + default: '', + description: + 'Field to set for the item to create. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + description: 'Value to set for the item to create', + }, + ], + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['item'], + operation: ['update'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + items: INodeExecutionData[], +): Promise { + const returnData: INodeExecutionData[] = []; + let responseData; + for (let i = 0; i < items.length; i++) { + try { + const collectionId = this.getNodeParameter('collectionId', i) as string; + const itemId = this.getNodeParameter('itemId', i) as string; + + const uiFields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as IDataObject[]; + + const live = this.getNodeParameter('live', i) as boolean; + + const fieldData = {} as IDataObject; + + uiFields.forEach((data) => (fieldData[data.fieldId as string] = data.fieldValue)); + + const body: IDataObject = { + fieldData, + }; + + responseData = await webflowApiRequest.call( + this, + 'PATCH', + `/collections/${collectionId}/items/${itemId}`, + body, + { live }, + ); + + const executionData = this.helpers.constructExecutionMetaData( + wrapData(responseData.body as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { message: error.message, error } }); + continue; + } + throw error; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Webflow/V2/actions/node.type.ts b/packages/nodes-base/nodes/Webflow/V2/actions/node.type.ts new file mode 100644 index 0000000000000..150a5684b6d52 --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V2/actions/node.type.ts @@ -0,0 +1,7 @@ +import type { AllEntities } from 'n8n-workflow'; + +type NodeMap = { + item: 'create' | 'deleteItem' | 'get' | 'getAll' | 'update'; +}; + +export type WebflowType = AllEntities; diff --git a/packages/nodes-base/nodes/Webflow/V2/actions/router.ts b/packages/nodes-base/nodes/Webflow/V2/actions/router.ts new file mode 100644 index 0000000000000..371a9df3c6df7 --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V2/actions/router.ts @@ -0,0 +1,35 @@ +import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import type { WebflowType } from './node.type'; + +import * as item from './Item/Item.resource'; + +export async function router(this: IExecuteFunctions): Promise { + let returnData: INodeExecutionData[] = []; + + const items = this.getInputData(); + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + + const webflowNodeData = { + resource, + operation, + } as WebflowType; + + try { + switch (webflowNodeData.resource) { + case 'item': + returnData = await item[webflowNodeData.operation].execute.call(this, items); + break; + default: + throw new NodeOperationError( + this.getNode(), + `The operation "${operation}" is not supported!`, + ); + } + } catch (error) { + throw error; + } + + return [returnData]; +} diff --git a/packages/nodes-base/nodes/Webflow/V2/actions/versionDescription.ts b/packages/nodes-base/nodes/Webflow/V2/actions/versionDescription.ts new file mode 100644 index 0000000000000..1f93d9493ddee --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/V2/actions/versionDescription.ts @@ -0,0 +1,41 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { INodeTypeDescription } from 'n8n-workflow'; + +import * as item from './Item/Item.resource'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Webflow', + name: 'webflow', + icon: 'file:webflow.svg', + group: ['transform'], + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume the Webflow API', + version: [2], + defaults: { + name: 'Webflow', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'webflowOAuth2Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Item', + value: 'item', + }, + ], + default: 'item', + }, + ...item.description, + ], +}; diff --git a/packages/nodes-base/nodes/Webflow/Webflow.node.ts b/packages/nodes-base/nodes/Webflow/Webflow.node.ts index 3f799cbf09eaa..4239c8c810abb 100644 --- a/packages/nodes-base/nodes/Webflow/Webflow.node.ts +++ b/packages/nodes-base/nodes/Webflow/Webflow.node.ts @@ -1,292 +1,26 @@ -import type { - IExecuteFunctions, - IDataObject, - ILoadOptionsFunctions, - INodeExecutionData, - INodePropertyOptions, - INodeType, - INodeTypeDescription, -} from 'n8n-workflow'; - -import { webflowApiRequest, webflowApiRequestAllItems } from './GenericFunctions'; - -import { itemFields, itemOperations } from './ItemDescription'; - -export class Webflow implements INodeType { - description: INodeTypeDescription = { - displayName: 'Webflow', - name: 'webflow', - icon: 'file:webflow.svg', - group: ['transform'], - version: 1, - subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - description: 'Consume the Webflow API', - defaults: { - name: 'Webflow', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'webflowApi', - required: true, - displayOptions: { - show: { - authentication: ['accessToken'], - }, - }, - }, - { - name: 'webflowOAuth2Api', - required: true, - displayOptions: { - show: { - authentication: ['oAuth2'], - }, - }, - }, - ], - properties: [ - { - displayName: 'Authentication', - name: 'authentication', - type: 'options', - options: [ - { - name: 'Access Token', - value: 'accessToken', - }, - { - name: 'OAuth2', - value: 'oAuth2', - }, - ], - default: 'accessToken', - }, - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Item', - value: 'item', - }, - ], - default: 'item', - }, - ...itemOperations, - ...itemFields, - ], - }; - - methods = { - loadOptions: { - async getSites(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const sites = await webflowApiRequest.call(this, 'GET', '/sites'); - for (const site of sites) { - returnData.push({ - name: site.name, - value: site._id, - }); - } - return returnData; - }, - async getCollections(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const siteId = this.getCurrentNodeParameter('siteId'); - const collections = await webflowApiRequest.call( - this, - 'GET', - `/sites/${siteId}/collections`, - ); - for (const collection of collections) { - returnData.push({ - name: collection.name, - value: collection._id, - }); - } - return returnData; - }, - async getFields(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const collectionId = this.getCurrentNodeParameter('collectionId'); - const { fields } = await webflowApiRequest.call( - this, - 'GET', - `/collections/${collectionId}`, - ); - for (const field of fields) { - returnData.push({ - name: `${field.name} (${field.type}) ${field.required ? ' (required)' : ''}`, - value: field.slug, - }); - } - return returnData; - }, - }, - }; - - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - - const resource = this.getNodeParameter('resource', 0); - const operation = this.getNodeParameter('operation', 0); - let responseData; - const returnData: INodeExecutionData[] = []; - - for (let i = 0; i < items.length; i++) { - try { - if (resource === 'item') { - // ********************************************************************* - // item - // ********************************************************************* - - // https://developers.webflow.com/#item-model - - if (operation === 'create') { - // ---------------------------------- - // item: create - // ---------------------------------- - - // https://developers.webflow.com/#create-new-collection-item - - const collectionId = this.getNodeParameter('collectionId', i) as string; - - const properties = this.getNodeParameter( - 'fieldsUi.fieldValues', - i, - [], - ) as IDataObject[]; - - const live = this.getNodeParameter('live', i) as boolean; - - const fields = {} as IDataObject; - - properties.forEach((data) => (fields[data.fieldId as string] = data.fieldValue)); - - const body: IDataObject = { - fields, - }; - - responseData = await webflowApiRequest.call( - this, - 'POST', - `/collections/${collectionId}/items`, - body, - { live }, - ); - } else if (operation === 'delete') { - // ---------------------------------- - // item: delete - // ---------------------------------- - - // https://developers.webflow.com/#remove-collection-item - - const collectionId = this.getNodeParameter('collectionId', i) as string; - const itemId = this.getNodeParameter('itemId', i) as string; - responseData = await webflowApiRequest.call( - this, - 'DELETE', - `/collections/${collectionId}/items/${itemId}`, - ); - } else if (operation === 'get') { - // ---------------------------------- - // item: get - // ---------------------------------- - - // https://developers.webflow.com/#get-single-item - - const collectionId = this.getNodeParameter('collectionId', i) as string; - const itemId = this.getNodeParameter('itemId', i) as string; - responseData = await webflowApiRequest.call( - this, - 'GET', - `/collections/${collectionId}/items/${itemId}`, - ); - responseData = responseData.items; - } else if (operation === 'getAll') { - // ---------------------------------- - // item: getAll - // ---------------------------------- - - // https://developers.webflow.com/#get-all-items-for-a-collection - - const returnAll = this.getNodeParameter('returnAll', 0); - const collectionId = this.getNodeParameter('collectionId', i) as string; - const qs: IDataObject = {}; - - if (returnAll) { - responseData = await webflowApiRequestAllItems.call( - this, - 'GET', - `/collections/${collectionId}/items`, - {}, - qs, - ); - } else { - qs.limit = this.getNodeParameter('limit', 0); - responseData = await webflowApiRequest.call( - this, - 'GET', - `/collections/${collectionId}/items`, - {}, - qs, - ); - responseData = responseData.items; - } - } else if (operation === 'update') { - // ---------------------------------- - // item: update - // ---------------------------------- - - // https://developers.webflow.com/#update-collection-item - - const collectionId = this.getNodeParameter('collectionId', i) as string; - - const itemId = this.getNodeParameter('itemId', i) as string; - - const properties = this.getNodeParameter( - 'fieldsUi.fieldValues', - i, - [], - ) as IDataObject[]; - - const live = this.getNodeParameter('live', i) as boolean; - - const fields = {} as IDataObject; - - properties.forEach((data) => (fields[data.fieldId as string] = data.fieldValue)); - - const body: IDataObject = { - fields, - }; - - responseData = await webflowApiRequest.call( - this, - 'PUT', - `/collections/${collectionId}/items/${itemId}`, - body, - { live }, - ); - } - } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } catch (error) { - if (this.continueOnFail(error)) { - returnData.push({ json: { error: error.message } }); - continue; - } - throw error; - } - } - - return [returnData]; +import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; + +import { WebflowV1 } from './V1/WebflowV1.node'; +import { WebflowV2 } from './V2/WebflowV2.node'; + +export class Webflow extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Webflow', + name: 'webflow', + icon: 'file:webflow.svg', + group: ['transform'], + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume the Webflow API', + defaultVersion: 2, + }; + + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new WebflowV1(baseDescription), + 2: new WebflowV2(baseDescription), + }; + + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts b/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts index 632bfb9534bd4..cdd2ee3814626 100644 --- a/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts +++ b/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts @@ -1,273 +1,24 @@ -import type { - IHookFunctions, - IWebhookFunctions, - IDataObject, - ILoadOptionsFunctions, - INodePropertyOptions, - INodeType, - INodeTypeDescription, - IWebhookResponseData, -} from 'n8n-workflow'; - -import { webflowApiRequest } from './GenericFunctions'; - -export class WebflowTrigger implements INodeType { - description: INodeTypeDescription = { - displayName: 'Webflow Trigger', - name: 'webflowTrigger', - icon: 'file:webflow.svg', - group: ['trigger'], - version: 1, - description: 'Handle Webflow events via webhooks', - defaults: { - name: 'Webflow Trigger', - }, - inputs: [], - outputs: ['main'], - credentials: [ - { - name: 'webflowApi', - required: true, - displayOptions: { - show: { - authentication: ['accessToken'], - }, - }, - }, - { - name: 'webflowOAuth2Api', - required: true, - displayOptions: { - show: { - authentication: ['oAuth2'], - }, - }, - }, - ], - webhooks: [ - { - name: 'default', - httpMethod: 'POST', - responseMode: 'onReceived', - path: 'webhook', - }, - ], - properties: [ - { - displayName: 'Authentication', - name: 'authentication', - type: 'options', - options: [ - { - name: 'Access Token', - value: 'accessToken', - }, - { - name: 'OAuth2', - value: 'oAuth2', - }, - ], - default: 'accessToken', - }, - { - displayName: 'Site Name or ID', - name: 'site', - type: 'options', - required: true, - default: '', - typeOptions: { - loadOptionsMethod: 'getSites', - }, - description: - 'Site that will trigger the events. Choose from the list, or specify an ID using an expression.', - }, - { - displayName: 'Event', - name: 'event', - type: 'options', - required: true, - options: [ - { - name: 'Collection Item Created', - value: 'collection_item_created', - }, - { - name: 'Collection Item Deleted', - value: 'collection_item_deleted', - }, - { - name: 'Collection Item Updated', - value: 'collection_item_changed', - }, - { - name: 'Ecomm Inventory Changed', - value: 'ecomm_inventory_changed', - }, - { - name: 'Ecomm New Order', - value: 'ecomm_new_order', - }, - { - name: 'Ecomm Order Changed', - value: 'ecomm_order_changed', - }, - { - name: 'Form Submission', - value: 'form_submission', - }, - { - name: 'Site Publish', - value: 'site_publish', - }, - ], - default: 'form_submission', - }, - // { - // displayName: 'All collections', - // name: 'allCollections', - // type: 'boolean', - // displayOptions: { - // show: { - // event: [ - // 'collection_item_created', - // 'collection_item_changed', - // 'collection_item_deleted', - // ], - // }, - // }, - // required: false, - // default: true, - // description: 'Receive events from all collections', - // }, - // { - // displayName: 'Collection', - // name: 'collection', - // type: 'options', - // required: false, - // default: '', - // typeOptions: { - // loadOptionsMethod: 'getCollections', - // loadOptionsDependsOn: [ - // 'site', - // ], - // }, - // description: 'Collection that will trigger the events', - // displayOptions: { - // show: { - // allCollections: [ - // false, - // ], - // }, - // }, - // }, - ], - }; - - methods = { - loadOptions: { - // Get all the sites to display them to user so that they can - // select them easily - async getSites(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const sites = await webflowApiRequest.call(this, 'GET', '/sites'); - for (const site of sites) { - const siteName = site.name; - const siteId = site._id; - returnData.push({ - name: siteName, - value: siteId, - }); - } - return returnData; - }, - // async getCollections(this: ILoadOptionsFunctions): Promise { - // const returnData: INodePropertyOptions[] = []; - // const siteId = this.getCurrentNodeParameter('site'); - // const collections = await webflowApiRequest.call(this, 'GET', `/sites/${siteId}/collections`); - // for (const collection of collections) { - // returnData.push({ - // name: collection.name, - // value: collection._id, - // }); - // } - // return returnData; - // }, - }, - }; - - webhookMethods = { - default: { - async checkExists(this: IHookFunctions): Promise { - const webhookData = this.getWorkflowStaticData('node'); - const webhookUrl = this.getNodeWebhookUrl('default'); - const siteId = this.getNodeParameter('site') as string; - - const event = this.getNodeParameter('event') as string; - const registeredWebhooks = (await webflowApiRequest.call( - this, - 'GET', - `/sites/${siteId}/webhooks`, - )) as IDataObject[]; - - for (const webhook of registeredWebhooks) { - if (webhook.url === webhookUrl && webhook.triggerType === event) { - webhookData.webhookId = webhook._id; - return true; - } - } - - return false; - }, - - async create(this: IHookFunctions): Promise { - const webhookUrl = this.getNodeWebhookUrl('default'); - const webhookData = this.getWorkflowStaticData('node'); - const siteId = this.getNodeParameter('site') as string; - const event = this.getNodeParameter('event') as string; - const endpoint = `/sites/${siteId}/webhooks`; - const body: IDataObject = { - site_id: siteId, - triggerType: event, - url: webhookUrl, - }; - - // if (event.startsWith('collection')) { - // const allCollections = this.getNodeParameter('allCollections') as boolean; - - // if (allCollections === false) { - // body.filter = { - // 'cid': this.getNodeParameter('collection') as string, - // }; - // } - // } - - const { _id } = await webflowApiRequest.call(this, 'POST', endpoint, body); - webhookData.webhookId = _id; - return true; - }, - async delete(this: IHookFunctions): Promise { - let responseData; - const webhookData = this.getWorkflowStaticData('node'); - const siteId = this.getNodeParameter('site') as string; - const endpoint = `/sites/${siteId}/webhooks/${webhookData.webhookId}`; - try { - responseData = await webflowApiRequest.call(this, 'DELETE', endpoint); - } catch (error) { - return false; - } - if (!responseData.deleted) { - return false; - } - delete webhookData.webhookId; - return true; - }, - }, - }; +import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; +import { WebflowTriggerV1 } from './V1/WebflowTriggerV1.node'; +import { WebflowTriggerV2 } from './V2/WebflowTriggerV2.node'; + +export class WebflowTrigger extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Webflow Trigger', + name: 'webflowTrigger', + icon: 'file:webflow.svg', + group: ['trigger'], + description: 'Handle Webflow events via webhooks', + defaultVersion: 2, + }; - async webhook(this: IWebhookFunctions): Promise { - const req = this.getRequestObject(); - return { - workflowData: [this.helpers.returnJsonArray(req.body as IDataObject[])], + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new WebflowTriggerV1(baseDescription), + 2: new WebflowTriggerV2(baseDescription), }; + + super(nodeVersions, baseDescription); } } From fa17391dbd1d6902682fda1f90d3e9437912dedf Mon Sep 17 00:00:00 2001 From: Val <68596159+valya@users.noreply.github.com> Date: Wed, 7 Aug 2024 10:19:09 +0100 Subject: [PATCH 10/19] feat: Return scopes on executions (no-changelog) (#10310) --- .../projectRelation.repository.ts | 8 ++++++ .../repositories/sharedWorkflow.repository.ts | 9 +++++++ .../__tests__/execution.service.test.ts | 1 + .../__tests__/executions.controller.test.ts | 2 ++ .../cli/src/executions/execution.service.ts | 15 +++++++++++ .../cli/src/executions/execution.types.ts | 10 ++++++- .../src/executions/executions.controller.ts | 16 +++++++++--- .../src/workflows/workflowSharing.service.ts | 26 +++++++++++++++++++ .../execution.service.integration.test.ts | 1 + .../integration/executions.controller.test.ts | 10 +++++++ 10 files changed, 94 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/databases/repositories/projectRelation.repository.ts b/packages/cli/src/databases/repositories/projectRelation.repository.ts index 00fc4de34a195..1f875d011fe06 100644 --- a/packages/cli/src/databases/repositories/projectRelation.repository.ts +++ b/packages/cli/src/databases/repositories/projectRelation.repository.ts @@ -61,4 +61,12 @@ export class ProjectRelationRepository extends Repository { return [...new Set(rows.map((r) => r.userId))]; } + + async findAllByUser(userId: string) { + return await this.find({ + where: { + userId, + }, + }); + } } diff --git a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts index 4dc54935cb193..d8e224fee2280 100644 --- a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts +++ b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts @@ -200,4 +200,13 @@ export class SharedWorkflowRepository extends Repository { }) )?.project; } + + async getRelationsByWorkflowIdsAndProjectIds(workflowIds: string[], projectIds: string[]) { + return await this.find({ + where: { + workflowId: In(workflowIds), + projectId: In(projectIds), + }, + }); + } } diff --git a/packages/cli/src/executions/__tests__/execution.service.test.ts b/packages/cli/src/executions/__tests__/execution.service.test.ts index 567e0c9758a6f..04c266a4d071e 100644 --- a/packages/cli/src/executions/__tests__/execution.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution.service.test.ts @@ -31,6 +31,7 @@ describe('ExecutionService', () => { mock(), concurrencyControl, mock(), + mock(), ); beforeEach(() => { diff --git a/packages/cli/src/executions/__tests__/executions.controller.test.ts b/packages/cli/src/executions/__tests__/executions.controller.test.ts index 2a4c733c5ab3c..decb88d59854e 100644 --- a/packages/cli/src/executions/__tests__/executions.controller.test.ts +++ b/packages/cli/src/executions/__tests__/executions.controller.test.ts @@ -74,6 +74,8 @@ describe('ExecutionsController', () => { }, ]; + executionService.findRangeWithCount.mockResolvedValue(NO_EXECUTIONS); + describe('if either status or range provided', () => { test.each(QUERIES_WITH_EITHER_STATUS_OR_RANGE)( 'should fetch executions per query', diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index bb8650e99f1d6..0823bde36fe7a 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -40,6 +40,8 @@ import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; import { License } from '@/License'; +import type { User } from '@/databases/entities/User'; +import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; export const schemaGetExecutionsQueryFilter = { $id: '/IGetExecutionsQueryFilter', @@ -92,6 +94,7 @@ export class ExecutionService { private readonly workflowRunner: WorkflowRunner, private readonly concurrencyControl: ConcurrencyControlService, private readonly license: License, + private readonly workflowSharingService: WorkflowSharingService, ) {} async findOne( @@ -478,4 +481,16 @@ export class ExecutionService { return await this.executionRepository.stopDuringRun(execution); } + + async addScopes(user: User, summaries: ExecutionSummaries.ExecutionSummaryWithScopes[]) { + const workflowIds = [...new Set(summaries.map((s) => s.workflowId))]; + + const scopes = Object.fromEntries( + await this.workflowSharingService.getSharedWorkflowScopes(workflowIds, user), + ); + + for (const s of summaries) { + s.scopes = scopes[s.workflowId] ?? []; + } + } } diff --git a/packages/cli/src/executions/execution.types.ts b/packages/cli/src/executions/execution.types.ts index 7e8872bf1b305..fd5024adbd718 100644 --- a/packages/cli/src/executions/execution.types.ts +++ b/packages/cli/src/executions/execution.types.ts @@ -1,6 +1,12 @@ import type { ExecutionEntity } from '@/databases/entities/ExecutionEntity'; import type { AuthenticatedRequest } from '@/requests'; -import type { ExecutionStatus, IDataObject, WorkflowExecuteMode } from 'n8n-workflow'; +import type { Scope } from '@n8n/permissions'; +import type { + ExecutionStatus, + ExecutionSummary, + IDataObject, + WorkflowExecuteMode, +} from 'n8n-workflow'; export declare namespace ExecutionRequest { namespace QueryParams { @@ -83,6 +89,8 @@ export namespace ExecutionSummaries { stoppedAt?: 'DESC'; }; }; + + export type ExecutionSummaryWithScopes = ExecutionSummary & { scopes: Scope[] }; } export type QueueRecoverySettings = { diff --git a/packages/cli/src/executions/executions.controller.ts b/packages/cli/src/executions/executions.controller.ts index 64b6a5427429f..c68c8cb7d5c48 100644 --- a/packages/cli/src/executions/executions.controller.ts +++ b/packages/cli/src/executions/executions.controller.ts @@ -1,4 +1,4 @@ -import { ExecutionRequest } from './execution.types'; +import { ExecutionRequest, type ExecutionSummaries } from './execution.types'; import { ExecutionService } from './execution.service'; import { Get, Post, RestController } from '@/decorators'; import { EnterpriseExecutionsService } from './execution.service.ee'; @@ -53,10 +53,20 @@ export class ExecutionsController { const noRange = !query.range.lastId || !query.range.firstId; if (noStatus && noRange) { - return await this.executionService.findLatestCurrentAndCompleted(query); + const executions = await this.executionService.findLatestCurrentAndCompleted(query); + await this.executionService.addScopes( + req.user, + executions.results as ExecutionSummaries.ExecutionSummaryWithScopes[], + ); + return executions; } - return await this.executionService.findRangeWithCount(query); + const executions = await this.executionService.findRangeWithCount(query); + await this.executionService.addScopes( + req.user, + executions.results as ExecutionSummaries.ExecutionSummaryWithScopes[], + ); + return executions; } @Get('/:id') diff --git a/packages/cli/src/workflows/workflowSharing.service.ts b/packages/cli/src/workflows/workflowSharing.service.ts index add5bb31b8fc9..111e50b58103f 100644 --- a/packages/cli/src/workflows/workflowSharing.service.ts +++ b/packages/cli/src/workflows/workflowSharing.service.ts @@ -8,12 +8,14 @@ import { RoleService } from '@/services/role.service'; import type { Scope } from '@n8n/permissions'; import type { ProjectRole } from '@/databases/entities/ProjectRelation'; import type { WorkflowSharingRole } from '@/databases/entities/SharedWorkflow'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; @Service() export class WorkflowSharingService { constructor( private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly roleService: RoleService, + private readonly projectRelationRepository: ProjectRelationRepository, ) {} /** @@ -64,4 +66,28 @@ export class WorkflowSharingService { return sharedWorkflows.map(({ workflowId }) => workflowId); } + + async getSharedWorkflowScopes( + workflowIds: string[], + user: User, + ): Promise> { + const projectRelations = await this.projectRelationRepository.findAllByUser(user.id); + const sharedWorkflows = + await this.sharedWorkflowRepository.getRelationsByWorkflowIdsAndProjectIds( + workflowIds, + projectRelations.map((p) => p.projectId), + ); + + return workflowIds.map((workflowId) => { + return [ + workflowId, + this.roleService.combineResourceScopes( + 'workflow', + user, + sharedWorkflows.filter((s) => s.workflowId === workflowId), + projectRelations, + ), + ]; + }); + } } diff --git a/packages/cli/test/integration/execution.service.integration.test.ts b/packages/cli/test/integration/execution.service.integration.test.ts index e30d55602af95..6f29950bf48d4 100644 --- a/packages/cli/test/integration/execution.service.integration.test.ts +++ b/packages/cli/test/integration/execution.service.integration.test.ts @@ -30,6 +30,7 @@ describe('ExecutionService', () => { mock(), mock(), mock(), + mock(), ); }); diff --git a/packages/cli/test/integration/executions.controller.test.ts b/packages/cli/test/integration/executions.controller.test.ts index 1e6d65a4f8368..94faa32351764 100644 --- a/packages/cli/test/integration/executions.controller.test.ts +++ b/packages/cli/test/integration/executions.controller.test.ts @@ -48,6 +48,16 @@ describe('GET /executions', () => { const response2 = await testServer.authAgentFor(member).get('/executions').expect(200); expect(response2.body.data.count).toBe(1); }); + + test('should return a scopes array for each execution', async () => { + testServer.license.enable('feat:sharing'); + const workflow = await createWorkflow({}, owner); + await shareWorkflowWithUsers(workflow, [member]); + await createSuccessfulExecution(workflow); + + const response = await testServer.authAgentFor(member).get('/executions').expect(200); + expect(response.body.data.results[0].scopes).toContain('workflow:execute'); + }); }); describe('GET /executions/:id', () => { From a93668076893a1ee824fe99c01a4737b7ee919a4 Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 7 Aug 2024 11:20:17 +0200 Subject: [PATCH 11/19] feat(HTTP Request Tool Node): Use DynamicStructuredTool with models supporting it (no-changelog) (#10246) --- .../agents/OpenAiFunctionsAgent/execute.ts | 2 +- .../agents/Agent/agents/ToolsAgent/execute.ts | 2 +- .../OpenAiAssistant/OpenAiAssistant.node.ts | 2 +- .../ToolHttpRequest/ToolHttpRequest.node.ts | 23 ++- .../nodes/tools/ToolHttpRequest/utils.ts | 67 +++++-- .../tools/ToolWorkflow/ToolWorkflow.node.ts | 2 +- .../actions/assistant/message.operation.ts | 2 +- .../OpenAi/actions/text/message.operation.ts | 2 +- .../nodes-langchain/utils/N8nTool.test.ts | 169 ++++++++++++++++++ .../@n8n/nodes-langchain/utils/N8nTool.ts | 113 ++++++++++++ .../@n8n/nodes-langchain/utils/helpers.ts | 24 ++- 11 files changed, 382 insertions(+), 26 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/utils/N8nTool.test.ts create mode 100644 packages/@n8n/nodes-langchain/utils/N8nTool.ts diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts index a027829e3a7d8..3c4ff28f06a10 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts @@ -38,7 +38,7 @@ export async function openAiFunctionsAgentExecute( const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as | BaseChatMemory | undefined; - const tools = await getConnectedTools(this, nodeVersion >= 1.5); + const tools = await getConnectedTools(this, nodeVersion >= 1.5, false); const outputParsers = await getOptionalOutputParsers(this); const options = this.getNodeParameter('options', 0, {}) as { systemMessage?: string; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts index 81ac6bad5db43..e0da7f1e315f9 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts @@ -90,7 +90,7 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise; + const tools = (await getConnectedTools(this, true, false)) as Array; const outputParser = (await getOptionalOutputParsers(this))?.[0]; let structuredOutputParserTool: DynamicStructuredTool | undefined; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts index f56ce7c5c4484..8f05faccb0274 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts @@ -313,7 +313,7 @@ export class OpenAiAssistant implements INodeType { async execute(this: IExecuteFunctions): Promise { const nodeVersion = this.getNode().typeVersion; - const tools = await getConnectedTools(this, nodeVersion > 1); + const tools = await getConnectedTools(this, nodeVersion > 1, false); const credentials = await this.getCredentials('openAiApi'); const items = this.getInputData(); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts index 1d391f313ef36..421e85e1b5b8c 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts @@ -12,6 +12,7 @@ import { NodeConnectionType, NodeOperationError, tryToParseAlphanumericString } import { DynamicTool } from '@langchain/core/tools'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { N8nTool } from '../../../utils/N8nTool'; import { configureHttpRequestFunction, configureResponseOptimizer, @@ -19,6 +20,7 @@ import { prepareToolDescription, configureToolFunction, updateParametersAndOptions, + makeToolInputSchema, } from './utils'; import { @@ -38,7 +40,7 @@ export class ToolHttpRequest implements INodeType { name: 'toolHttpRequest', icon: { light: 'file:httprequest.svg', dark: 'file:httprequest.dark.svg' }, group: ['output'], - version: 1, + version: [1, 1.1], description: 'Makes an HTTP request and returns the response data', subtitle: '={{ $parameter.toolDescription }}', defaults: { @@ -394,9 +396,24 @@ export class ToolHttpRequest implements INodeType { optimizeResponse, ); - const description = prepareToolDescription(toolDescription, toolParameters); + let tool: DynamicTool | N8nTool; - const tool = new DynamicTool({ name, description, func }); + // If the node version is 1.1 or higher, we use the N8nTool wrapper: + // it allows to use tool as a DynamicStructuredTool and have a fallback to DynamicTool + if (this.getNode().typeVersion >= 1.1) { + const schema = makeToolInputSchema(toolParameters); + + tool = new N8nTool(this, { + name, + description: toolDescription, + func, + schema, + }); + } else { + // Keep the old behavior for nodes with version 1.0 + const description = prepareToolDescription(toolDescription, toolParameters); + tool = new DynamicTool({ name, description, func }); + } return { response: tool, diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index 28015b588a101..96ae0d8492a33 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -27,6 +27,8 @@ import type { SendIn, ToolParameter, } from './interfaces'; +import type { DynamicZodObject } from '../../../types/zod.types'; +import { z } from 'zod'; const genericCredentialRequest = async (ctx: IExecuteFunctions, itemIndex: number) => { const genericType = ctx.getNodeParameter('genericAuthType', itemIndex) as string; @@ -566,7 +568,7 @@ export const configureToolFunction = ( httpRequest: (options: IHttpRequestOptions) => Promise, optimizeResponse: (response: string) => string, ) => { - return async (query: string): Promise => { + return async (query: string | IDataObject): Promise => { const { index } = ctx.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); let response: string = ''; @@ -581,18 +583,22 @@ export const configureToolFunction = ( if (query) { let dataFromModel; - try { - dataFromModel = jsonParse(query); - } catch (error) { - if (toolParameters.length === 1) { - dataFromModel = { [toolParameters[0].name]: query }; - } else { - throw new NodeOperationError( - ctx.getNode(), - `Input is not a valid JSON: ${error.message}`, - { itemIndex }, - ); + if (typeof query === 'string') { + try { + dataFromModel = jsonParse(query); + } catch (error) { + if (toolParameters.length === 1) { + dataFromModel = { [toolParameters[0].name]: query }; + } else { + throw new NodeOperationError( + ctx.getNode(), + `Input is not a valid JSON: ${error.message}`, + { itemIndex }, + ); + } } + } else { + dataFromModel = query; } for (const parameter of toolParameters) { @@ -727,6 +733,8 @@ export const configureToolFunction = ( } } } catch (error) { + console.error(error); + const errorMessage = 'Input provided by model is not valid'; if (error instanceof NodeOperationError) { @@ -765,3 +773,38 @@ export const configureToolFunction = ( return response; }; }; + +function makeParameterZodSchema(parameter: ToolParameter) { + let schema: z.ZodTypeAny; + + if (parameter.type === 'string') { + schema = z.string(); + } else if (parameter.type === 'number') { + schema = z.number(); + } else if (parameter.type === 'boolean') { + schema = z.boolean(); + } else if (parameter.type === 'json') { + schema = z.record(z.any()); + } else { + schema = z.string(); + } + + if (!parameter.required) { + schema = schema.optional(); + } + + if (parameter.description) { + schema = schema.describe(parameter.description); + } + + return schema; +} + +export function makeToolInputSchema(parameters: ToolParameter[]): DynamicZodObject { + const schemaEntries = parameters.map((parameter) => [ + parameter.name, + makeParameterZodSchema(parameter), + ]); + + return z.object(Object.fromEntries(schemaEntries)); +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index 54b8318f5b8b8..5ed96cbd60123 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -493,7 +493,7 @@ export class ToolWorkflow implements INodeType { if (useSchema) { try { // We initialize these even though one of them will always be empty - // it makes it easer to navigate the ternary operator + // it makes it easier to navigate the ternary operator const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts index da2770d05f2eb..134e8a1167924 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts @@ -163,7 +163,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise 1); + const tools = await getConnectedTools(this, nodeVersion > 1, false); let assistantTools; if (tools.length) { diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts index 4cf72e9f5f48c..d37be5a065e6d 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts @@ -219,7 +219,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise 1; - externalTools = await getConnectedTools(this, enforceUniqueNames); + externalTools = await getConnectedTools(this, enforceUniqueNames, false); } if (externalTools.length) { diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts new file mode 100644 index 0000000000000..6f12b18079551 --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts @@ -0,0 +1,169 @@ +import { N8nTool } from './N8nTool'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import { z } from 'zod'; +import type { INode } from 'n8n-workflow'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; + +const mockNode: INode = { + id: '1', + name: 'Mock node', + typeVersion: 2, + type: 'n8n-nodes-base.mock', + position: [60, 760], + parameters: { + operation: 'test', + }, +}; + +describe('Test N8nTool wrapper as DynamicStructuredTool', () => { + it('should wrap a tool', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string(), + }), + }); + + expect(tool).toBeInstanceOf(DynamicStructuredTool); + }); +}); + +describe('Test N8nTool wrapper - DynamicTool fallback', () => { + it('should convert the tool to a dynamic tool', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string(), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + expect(dynamicTool).toBeInstanceOf(DynamicTool); + }); + + it('should format fallback description correctly', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string(), + bar: z.number().optional(), + qwe: z.boolean().describe('Boolean description'), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + expect(dynamicTool.description).toContain('foo: (description: , type: string, required: true)'); + expect(dynamicTool.description).toContain( + 'bar: (description: , type: number, required: false)', + ); + + expect(dynamicTool.description).toContain( + 'qwe: (description: Boolean description, type: boolean, required: true)', + ); + }); + + it('should handle empty parameter list correctly', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({}), + }); + + const dynamicTool = tool.asDynamicTool(); + + expect(dynamicTool.description).toEqual('A dummy tool for testing'); + }); + + it('should parse correct parameters', async () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string().describe('Foo description'), + bar: z.number().optional(), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + const testParameters = { foo: 'some value' }; + + await dynamicTool.func(JSON.stringify(testParameters)); + + expect(func).toHaveBeenCalledWith(testParameters); + }); + + it('should recover when 1 parameter is passed directly', async () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string().describe('Foo description'), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + const testParameter = 'some value'; + + await dynamicTool.func(testParameter); + + expect(func).toHaveBeenCalledWith({ foo: testParameter }); + }); + + it('should recover when JS object is passed instead of JSON', async () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string().describe('Foo description'), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + await dynamicTool.func('{ foo: "some value" }'); + + expect(func).toHaveBeenCalledWith({ foo: 'some value' }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.ts new file mode 100644 index 0000000000000..bb8bab08bd4fb --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.ts @@ -0,0 +1,113 @@ +import type { DynamicStructuredToolInput } from '@langchain/core/tools'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import type { IExecuteFunctions, IDataObject } from 'n8n-workflow'; +import { NodeConnectionType, jsonParse, NodeOperationError } from 'n8n-workflow'; +import { StructuredOutputParser } from 'langchain/output_parsers'; +import type { ZodTypeAny } from 'zod'; +import { ZodBoolean, ZodNullable, ZodNumber, ZodObject, ZodOptional } from 'zod'; + +const getSimplifiedType = (schema: ZodTypeAny) => { + if (schema instanceof ZodObject) { + return 'object'; + } else if (schema instanceof ZodNumber) { + return 'number'; + } else if (schema instanceof ZodBoolean) { + return 'boolean'; + } else if (schema instanceof ZodNullable || schema instanceof ZodOptional) { + return getSimplifiedType(schema.unwrap()); + } + + return 'string'; +}; + +const getParametersDescription = (parameters: Array<[string, ZodTypeAny]>) => + parameters + .map( + ([name, schema]) => + `${name}: (description: ${schema.description ?? ''}, type: ${getSimplifiedType(schema)}, required: ${!schema.isOptional()})`, + ) + .join(',\n '); + +export const prepareFallbackToolDescription = (toolDescription: string, schema: ZodObject) => { + let description = `${toolDescription}`; + + const toolParameters = Object.entries(schema.shape); + + if (toolParameters.length) { + description += ` +Tool expects valid stringified JSON object with ${toolParameters.length} properties. +Property names with description, type and required status: +${getParametersDescription(toolParameters)} +ALL parameters marked as required must be provided`; + } + + return description; +}; + +export class N8nTool extends DynamicStructuredTool { + private context: IExecuteFunctions; + + constructor(context: IExecuteFunctions, fields: DynamicStructuredToolInput) { + super(fields); + + this.context = context; + } + + asDynamicTool(): DynamicTool { + const { name, func, schema, context, description } = this; + + const parser = new StructuredOutputParser(schema); + + const wrappedFunc = async function (query: string) { + let parsedQuery: object; + + // First we try to parse the query using the structured parser (Zod schema) + try { + parsedQuery = await parser.parse(query); + } catch (e) { + // If we were unable to parse the query using the schema, we try to gracefully handle it + let dataFromModel; + + try { + // First we try to parse a JSON with more relaxed rules + dataFromModel = jsonParse(query, { acceptJSObject: true }); + } catch (error) { + // In case of error, + // If model supplied a simple string instead of an object AND only one parameter expected, we try to recover the object structure + if (Object.keys(schema.shape).length === 1) { + const parameterName = Object.keys(schema.shape)[0]; + dataFromModel = { [parameterName]: query }; + } else { + // Finally throw an error if we were unable to parse the query + throw new NodeOperationError( + context.getNode(), + `Input is not a valid JSON: ${error.message}`, + ); + } + } + + // If we were able to parse the query with a fallback, we try to validate it using the schema + // Here we will throw an error if the data still does not match the schema + parsedQuery = schema.parse(dataFromModel); + } + + try { + // Call tool function with parsed query + const result = await func(parsedQuery); + + return result; + } catch (e) { + const { index } = context.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); + void context.addOutputData(NodeConnectionType.AiTool, index, e); + + return e.toString(); + } + }; + + return new DynamicTool({ + name, + description: prepareFallbackToolDescription(description, schema), + func: wrappedFunc, + }); + } +} diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index c6d27ee2f69f3..673fa0402c483 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -1,17 +1,19 @@ -import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow'; import type { EventNamesAiNodesType, IDataObject, IExecuteFunctions, IWebhookFunctions, } from 'n8n-workflow'; +import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { BaseOutputParser } from '@langchain/core/output_parsers'; import type { BaseMessage } from '@langchain/core/messages'; -import { DynamicTool, type Tool } from '@langchain/core/tools'; +import type { Tool } from '@langchain/core/tools'; import type { BaseLLM } from '@langchain/core/language_models/llms'; import type { BaseChatMemory } from 'langchain/memory'; import type { BaseChatMessageHistory } from '@langchain/core/chat_history'; +import { N8nTool } from './N8nTool'; +import { DynamicTool } from '@langchain/core/tools'; function hasMethods(obj: unknown, ...methodNames: Array): obj is T { return methodNames.every( @@ -178,7 +180,11 @@ export function serializeChatHistory(chatHistory: BaseMessage[]): string { .join('\n'); } -export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNames: boolean) => { +export const getConnectedTools = async ( + ctx: IExecuteFunctions, + enforceUniqueNames: boolean, + convertStructuredTool: boolean = true, +) => { const connectedTools = ((await ctx.getInputConnectionData(NodeConnectionType.AiTool, 0)) as Tool[]) || []; @@ -186,8 +192,10 @@ export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNam const seenNames = new Set(); + const finalTools = []; + for (const tool of connectedTools) { - if (!(tool instanceof DynamicTool)) continue; + if (!(tool instanceof DynamicTool) && !(tool instanceof N8nTool)) continue; const { name } = tool; if (seenNames.has(name)) { @@ -197,7 +205,13 @@ export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNam ); } seenNames.add(name); + + if (convertStructuredTool && tool instanceof N8nTool) { + finalTools.push(tool.asDynamicTool()); + } else { + finalTools.push(tool); + } } - return connectedTools; + return finalTools; }; From c5acbb7ec0d24ec9b30c221fa3b2fb615fb9ec7f Mon Sep 17 00:00:00 2001 From: CodeShakingSheep <19874562+CodeShakingSheep@users.noreply.github.com> Date: Wed, 7 Aug 2024 04:38:50 -0500 Subject: [PATCH 12/19] fix(Invoice Ninja Node): Fix payment types (#10196) --- .../nodes/InvoiceNinja/ExpenseDescription.ts | 206 ++++++++++++++++++ .../nodes/InvoiceNinja/PaymentDescription.ts | 206 ++++++++++++++++++ 2 files changed, 412 insertions(+) diff --git a/packages/nodes-base/nodes/InvoiceNinja/ExpenseDescription.ts b/packages/nodes-base/nodes/InvoiceNinja/ExpenseDescription.ts index 310c66b9c18ac..729c6a8b77a4b 100644 --- a/packages/nodes-base/nodes/InvoiceNinja/ExpenseDescription.ts +++ b/packages/nodes-base/nodes/InvoiceNinja/ExpenseDescription.ts @@ -120,6 +120,11 @@ export const expenseFields: INodeProperties[] = [ displayName: 'Payment Type', name: 'paymentType', type: 'options', + displayOptions: { + show: { + apiVersion: ['v4'], + }, + }, options: [ { name: 'ACH', @@ -252,6 +257,207 @@ export const expenseFields: INodeProperties[] = [ ], default: 1, }, + { + displayName: 'Payment Type', + name: 'paymentType', + type: 'options', + displayOptions: { + show: { + apiVersion: ['v5'], + }, + }, + options: [ + { + name: 'Bank Transfer', + value: 1, + }, + { + name: 'Cash', + value: 2, + }, + { + name: 'ACH', + value: 4, + }, + { + name: 'Visa', + value: 5, + }, + { + name: 'Mastercard', + value: 6, + }, + { + name: 'American Express', + value: 7, + }, + { + name: 'Discover', + value: 8, + }, + { + name: 'Diners', + value: 9, + }, + { + name: 'Eurocard', + value: 10, + }, + { + name: 'Nova', + value: 11, + }, + { + name: 'Credit Card Other', + value: 12, + }, + { + name: 'PayPal', + value: 13, + }, + { + name: 'Check', + value: 15, + }, + { + name: 'Carte Blanche', + value: 16, + }, + { + name: 'UnionPay', + value: 17, + }, + { + name: 'JCB', + value: 18, + }, + { + name: 'Laser', + value: 19, + }, + { + name: 'Maestro', + value: 20, + }, + { + name: 'Solo', + value: 21, + }, + { + name: 'Switch', + value: 22, + }, + { + name: 'Venmo', + value: 24, + }, + { + name: 'Alipay', + value: 27, + }, + { + name: 'Sofort', + value: 28, + }, + { + name: 'SEPA', + value: 29, + }, + { + name: 'GoCardless', + value: 30, + }, + { + name: 'Crypto', + value: 31, + }, + { + name: 'Credit', + value: 32, + }, + { + name: 'Zelle', + value: 33, + }, + { + name: 'Mollie Bank Transfer', + value: 34, + }, + { + name: 'KBC', + value: 35, + }, + { + name: 'Bancontact', + value: 36, + }, + { + name: 'iDEAL', + value: 37, + }, + { + name: 'Hosted Page', + value: 38, + }, + { + name: 'Giropay', + value: 39, + }, + { + name: 'Przelewy24', + value: 40, + }, + { + name: 'EPS', + value: 41, + }, + { + name: 'Direct Debit', + value: 42, + }, + { + name: 'BECS', + value: 43, + }, + { + name: 'ACSS', + value: 44, + }, + { + name: 'Instant Bank Pay', + value: 45, + }, + { + name: 'FPX', + value: 46, + }, + { + name: 'Klarna', + value: 47, + }, + { + name: 'Interac E-Transfer', + value: 48, + }, + { + name: 'BACS', + value: 49, + }, + { + name: 'Stripe Bank Transfer', + value: 50, + }, + { + name: 'Cash App', + value: 51, + }, + { + name: 'Pay Later', + value: 52, + }, + ], + default: 1, + }, { displayName: 'Private Notes', name: 'privateNotes', diff --git a/packages/nodes-base/nodes/InvoiceNinja/PaymentDescription.ts b/packages/nodes-base/nodes/InvoiceNinja/PaymentDescription.ts index ff1a4562598ef..17ae0938e99c5 100644 --- a/packages/nodes-base/nodes/InvoiceNinja/PaymentDescription.ts +++ b/packages/nodes-base/nodes/InvoiceNinja/PaymentDescription.ts @@ -94,6 +94,11 @@ export const paymentFields: INodeProperties[] = [ displayName: 'Payment Type', name: 'paymentType', type: 'options', + displayOptions: { + show: { + apiVersion: ['v4'], + }, + }, options: [ { name: 'ACH', @@ -226,6 +231,207 @@ export const paymentFields: INodeProperties[] = [ ], default: 1, }, + { + displayName: 'Payment Type', + name: 'paymentType', + type: 'options', + displayOptions: { + show: { + apiVersion: ['v5'], + }, + }, + options: [ + { + name: 'Bank Transfer', + value: 1, + }, + { + name: 'Cash', + value: 2, + }, + { + name: 'ACH', + value: 4, + }, + { + name: 'Visa', + value: 5, + }, + { + name: 'Mastercard', + value: 6, + }, + { + name: 'American Express', + value: 7, + }, + { + name: 'Discover', + value: 8, + }, + { + name: 'Diners', + value: 9, + }, + { + name: 'Eurocard', + value: 10, + }, + { + name: 'Nova', + value: 11, + }, + { + name: 'Credit Card Other', + value: 12, + }, + { + name: 'PayPal', + value: 13, + }, + { + name: 'Check', + value: 15, + }, + { + name: 'Carte Blanche', + value: 16, + }, + { + name: 'UnionPay', + value: 17, + }, + { + name: 'JCB', + value: 18, + }, + { + name: 'Laser', + value: 19, + }, + { + name: 'Maestro', + value: 20, + }, + { + name: 'Solo', + value: 21, + }, + { + name: 'Switch', + value: 22, + }, + { + name: 'Venmo', + value: 24, + }, + { + name: 'Alipay', + value: 27, + }, + { + name: 'Sofort', + value: 28, + }, + { + name: 'SEPA', + value: 29, + }, + { + name: 'GoCardless', + value: 30, + }, + { + name: 'Crypto', + value: 31, + }, + { + name: 'Credit', + value: 32, + }, + { + name: 'Zelle', + value: 33, + }, + { + name: 'Mollie Bank Transfer', + value: 34, + }, + { + name: 'KBC', + value: 35, + }, + { + name: 'Bancontact', + value: 36, + }, + { + name: 'iDEAL', + value: 37, + }, + { + name: 'Hosted Page', + value: 38, + }, + { + name: 'Giropay', + value: 39, + }, + { + name: 'Przelewy24', + value: 40, + }, + { + name: 'EPS', + value: 41, + }, + { + name: 'Direct Debit', + value: 42, + }, + { + name: 'BECS', + value: 43, + }, + { + name: 'ACSS', + value: 44, + }, + { + name: 'Instant Bank Pay', + value: 45, + }, + { + name: 'FPX', + value: 46, + }, + { + name: 'Klarna', + value: 47, + }, + { + name: 'Interac E-Transfer', + value: 48, + }, + { + name: 'BACS', + value: 49, + }, + { + name: 'Stripe Bank Transfer', + value: 50, + }, + { + name: 'Cash App', + value: 51, + }, + { + name: 'Pay Later', + value: 52, + }, + ], + default: 1, + }, { displayName: 'Transfer Reference', name: 'transferReference', From 0a84e0d8b047669f5cf023c21383d01c929c5b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Andrieu?= Date: Wed, 7 Aug 2024 11:39:44 +0200 Subject: [PATCH 13/19] feat(MongoDB Node): Add projection to query options on Find (#9972) Co-authored-by: Jonathan Bennetts --- packages/nodes-base/nodes/MongoDb/MongoDb.node.ts | 6 ++++++ .../nodes-base/nodes/MongoDb/MongoDbProperties.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts index 1a200d9bd9e1d..8d5de2ea41aa5 100644 --- a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts +++ b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts @@ -196,6 +196,8 @@ export class MongoDb implements INodeType { const options = this.getNodeParameter('options', i); const limit = options.limit as number; const skip = options.skip as number; + const projection = + options.projection && (JSON.parse(options.projection as string) as Document); const sort = options.sort && (JSON.parse(options.sort as string) as Sort); if (skip > 0) { @@ -208,6 +210,10 @@ export class MongoDb implements INodeType { query = query.sort(sort); } + if (projection && projection instanceof Document) { + query = query.project(projection); + } + const queryResult = await query.toArray(); for (const entry of queryResult) { diff --git a/packages/nodes-base/nodes/MongoDb/MongoDbProperties.ts b/packages/nodes-base/nodes/MongoDb/MongoDbProperties.ts index a831bd57fa305..95b7fb93a5db9 100644 --- a/packages/nodes-base/nodes/MongoDb/MongoDbProperties.ts +++ b/packages/nodes-base/nodes/MongoDb/MongoDbProperties.ts @@ -151,6 +151,18 @@ export const nodeProperties: INodeProperties[] = [ placeholder: '{ "field": -1 }', description: 'A JSON that defines the sort order of the result set', }, + { + displayName: 'Projection (JSON Format)', + name: 'projection', + type: 'json', + typeOptions: { + rows: 4, + }, + default: '{}', + placeholder: '{ "_id": 0, "field": 1 }', + description: + 'A JSON that defines a selection of fields to retrieve or exclude from the result set', + }, ], }, { @@ -248,7 +260,7 @@ export const nodeProperties: INodeProperties[] = [ name: 'dateFields', type: 'string', default: '', - description: 'Comma separeted list of fields that will be parsed as Mongo Date type', + description: 'Comma-separated list of fields that will be parsed as Mongo Date type', }, { displayName: 'Use Dot Notation', From 34334651e0e6874736a437a894176bed4590e5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ferrer=20Gonz=C3=A1lez?= Date: Wed, 7 Aug 2024 11:40:04 +0200 Subject: [PATCH 14/19] feat(Stripe Trigger Node): Add Stripe webhook descriptions based on the workflow ID and name (#9956) --- packages/nodes-base/nodes/Stripe/StripeTrigger.node.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/nodes-base/nodes/Stripe/StripeTrigger.node.ts b/packages/nodes-base/nodes/Stripe/StripeTrigger.node.ts index 6d2837237e4fb..fb9b3fb563b8d 100644 --- a/packages/nodes-base/nodes/Stripe/StripeTrigger.node.ts +++ b/packages/nodes-base/nodes/Stripe/StripeTrigger.node.ts @@ -865,12 +865,15 @@ export class StripeTrigger implements INodeType { async create(this: IHookFunctions): Promise { const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookDescription = `Created by n8n for workflow ID: ${this.getWorkflow().id}`; + const events = this.getNodeParameter('events', []); const endpoint = '/webhook_endpoints'; const body = { url: webhookUrl, + description: webhookDescription, enabled_events: events, }; From bf8f848645dfd31527713a55bd1fc93865327017 Mon Sep 17 00:00:00 2001 From: pemontto <939704+pemontto@users.noreply.github.com> Date: Wed, 7 Aug 2024 10:42:07 +0100 Subject: [PATCH 15/19] feat(Elasticsearch Node): Add bulk operations for Elasticsearch (#9940) --- .../Elasticsearch/Elasticsearch.node.ts | 137 +++++++++++++++--- .../Elastic/Elasticsearch/GenericFunctions.ts | 50 ++++++- .../descriptions/DocumentDescription.ts | 36 +++++ 3 files changed, 202 insertions(+), 21 deletions(-) diff --git a/packages/nodes-base/nodes/Elastic/Elasticsearch/Elasticsearch.node.ts b/packages/nodes-base/nodes/Elastic/Elasticsearch/Elasticsearch.node.ts index c209b84538bff..d94ac77725464 100644 --- a/packages/nodes-base/nodes/Elastic/Elasticsearch/Elasticsearch.node.ts +++ b/packages/nodes-base/nodes/Elastic/Elasticsearch/Elasticsearch.node.ts @@ -4,11 +4,16 @@ import type { INodeExecutionData, INodeType, INodeTypeDescription, + JsonObject, } from 'n8n-workflow'; -import { jsonParse } from 'n8n-workflow'; +import { jsonParse, NodeApiError } from 'n8n-workflow'; import omit from 'lodash/omit'; -import { elasticsearchApiRequest, elasticsearchApiRequestAllItems } from './GenericFunctions'; +import { + elasticsearchApiRequest, + elasticsearchApiRequestAllItems, + elasticsearchBulkApiRequest, +} from './GenericFunctions'; import { documentFields, documentOperations, indexFields, indexOperations } from './descriptions'; @@ -68,12 +73,14 @@ export class Elasticsearch implements INodeType { let responseData; + let bulkBody: IDataObject = {}; + for (let i = 0; i < items.length; i++) { + const bulkOperation = this.getNodeParameter('options.bulkOperation', i, false); if (resource === 'document') { // ********************************************************************** // document // ********************************************************************** - if (operation === 'delete') { // ---------------------------------------- // document: delete @@ -84,8 +91,17 @@ export class Elasticsearch implements INodeType { const indexId = this.getNodeParameter('indexId', i); const documentId = this.getNodeParameter('documentId', i); - const endpoint = `/${indexId}/_doc/${documentId}`; - responseData = await elasticsearchApiRequest.call(this, 'DELETE', endpoint); + if (bulkOperation) { + bulkBody[i] = JSON.stringify({ + delete: { + _index: indexId, + _id: documentId, + }, + }); + } else { + const endpoint = `/${indexId}/_doc/${documentId}`; + responseData = await elasticsearchApiRequest.call(this, 'DELETE', endpoint); + } } else if (operation === 'get') { // ---------------------------------------- // document: get @@ -223,12 +239,22 @@ export class Elasticsearch implements INodeType { const indexId = this.getNodeParameter('indexId', i); const { documentId } = additionalFields; - if (documentId) { - const endpoint = `/${indexId}/_doc/${documentId}`; - responseData = await elasticsearchApiRequest.call(this, 'PUT', endpoint, body); + if (bulkOperation) { + bulkBody[i] = JSON.stringify({ + index: { + _index: indexId, + _id: documentId, + }, + }); + bulkBody[i] += `\n${JSON.stringify(body)}`; } else { - const endpoint = `/${indexId}/_doc`; - responseData = await elasticsearchApiRequest.call(this, 'POST', endpoint, body); + if (documentId) { + const endpoint = `/${indexId}/_doc/${documentId}`; + responseData = await elasticsearchApiRequest.call(this, 'PUT', endpoint, body); + } else { + const endpoint = `/${indexId}/_doc`; + responseData = await elasticsearchApiRequest.call(this, 'POST', endpoint, body); + } } } else if (operation === 'update') { // ---------------------------------------- @@ -261,7 +287,17 @@ export class Elasticsearch implements INodeType { const documentId = this.getNodeParameter('documentId', i); const endpoint = `/${indexId}/_update/${documentId}`; - responseData = await elasticsearchApiRequest.call(this, 'POST', endpoint, body); + if (bulkOperation) { + bulkBody[i] = JSON.stringify({ + update: { + _index: indexId, + _id: documentId, + }, + }); + bulkBody[i] += `\n${JSON.stringify(body)}`; + } else { + responseData = await elasticsearchApiRequest.call(this, 'POST', endpoint, body); + } } } else if (resource === 'index') { // ********************************************************************** @@ -341,13 +377,80 @@ export class Elasticsearch implements INodeType { } } } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } + if (!bulkOperation) { + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } + if (Object.keys(bulkBody).length >= 50) { + responseData = (await elasticsearchBulkApiRequest.call(this, bulkBody)) as IDataObject[]; + for (let j = 0; j < responseData.length; j++) { + const itemData = responseData[j]; + if (itemData.error) { + const errorData = itemData.error as IDataObject; + const message = errorData.type as string; + const description = errorData.reason as string; + const itemIndex = parseInt(Object.keys(bulkBody)[j]); + if (this.continueOnFail()) { + returnData.push( + ...this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: message, message: itemData.error }), + { itemData: { item: itemIndex } }, + ), + ); + continue; + } else { + throw new NodeApiError(this.getNode(), { + message, + description, + itemIndex, + } as JsonObject); + } + } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(itemData), + { itemData: { item: parseInt(Object.keys(bulkBody)[j]) } }, + ); + returnData.push(...executionData); + } + bulkBody = {}; + } + } + if (Object.keys(bulkBody).length) { + responseData = (await elasticsearchBulkApiRequest.call(this, bulkBody)) as IDataObject[]; + for (let j = 0; j < responseData.length; j++) { + const itemData = responseData[j]; + if (itemData.error) { + const errorData = itemData.error as IDataObject; + const message = errorData.type as string; + const description = errorData.reason as string; + const itemIndex = parseInt(Object.keys(bulkBody)[j]); + if (this.continueOnFail()) { + returnData.push( + ...this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: message, message: itemData.error }), + { itemData: { item: itemIndex } }, + ), + ); + continue; + } else { + throw new NodeApiError(this.getNode(), { + message, + description, + itemIndex, + } as JsonObject); + } + } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(itemData), + { itemData: { item: parseInt(Object.keys(bulkBody)[j]) } }, + ); + returnData.push(...executionData); + } + } return [returnData]; } } diff --git a/packages/nodes-base/nodes/Elastic/Elasticsearch/GenericFunctions.ts b/packages/nodes-base/nodes/Elastic/Elasticsearch/GenericFunctions.ts index ed013079f8cc6..642ce1484fe4f 100644 --- a/packages/nodes-base/nodes/Elastic/Elasticsearch/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Elastic/Elasticsearch/GenericFunctions.ts @@ -2,13 +2,55 @@ import type { IExecuteFunctions, IDataObject, JsonObject, - IRequestOptions, + IHttpRequestOptions, IHttpRequestMethods, } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; import type { ElasticsearchApiCredentials } from './types'; +export async function elasticsearchBulkApiRequest(this: IExecuteFunctions, body: IDataObject) { + const { baseUrl, ignoreSSLIssues } = (await this.getCredentials( + 'elasticsearchApi', + )) as ElasticsearchApiCredentials; + + const bulkBody = Object.values(body).flat().join('\n') + '\n'; + + const options: IHttpRequestOptions = { + method: 'POST', + headers: { 'Content-Type': 'application/x-ndjson' }, + body: bulkBody, + url: `${baseUrl}/_bulk`, + skipSslCertificateValidation: ignoreSSLIssues, + returnFullResponse: true, + ignoreHttpStatusErrors: true, + }; + + const response = await this.helpers.httpRequestWithAuthentication.call( + this, + 'elasticsearchApi', + options, + ); + + if (response.statusCode > 299) { + if (this.continueOnFail()) { + return Object.values(body).map((_) => ({ error: response.body.error })); + } else { + throw new NodeApiError(this.getNode(), { error: response.body.error } as JsonObject); + } + } + + return response.body.items.map((item: IDataObject) => { + return { + ...(item.index as IDataObject), + ...(item.update as IDataObject), + ...(item.create as IDataObject), + ...(item.delete as IDataObject), + ...(item.error as IDataObject), + }; + }); +} + export async function elasticsearchApiRequest( this: IExecuteFunctions, method: IHttpRequestMethods, @@ -20,13 +62,13 @@ export async function elasticsearchApiRequest( 'elasticsearchApi', )) as ElasticsearchApiCredentials; - const options: IRequestOptions = { + const options: IHttpRequestOptions = { method, body, qs, - uri: `${baseUrl}${endpoint}`, + url: `${baseUrl}${endpoint}`, json: true, - rejectUnauthorized: !ignoreSSLIssues, + skipSslCertificateValidation: ignoreSSLIssues, }; if (!Object.keys(body).length) { diff --git a/packages/nodes-base/nodes/Elastic/Elasticsearch/descriptions/DocumentDescription.ts b/packages/nodes-base/nodes/Elastic/Elasticsearch/descriptions/DocumentDescription.ts index 86cefdf4bd16a..3a446243ace73 100644 --- a/packages/nodes-base/nodes/Elastic/Elasticsearch/descriptions/DocumentDescription.ts +++ b/packages/nodes-base/nodes/Elastic/Elasticsearch/descriptions/DocumentDescription.ts @@ -81,6 +81,28 @@ export const documentFields: INodeProperties[] = [ }, }, }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['document'], + operation: ['delete'], + }, + }, + options: [ + { + displayName: 'Bulk Delete', + name: 'bulkOperation', + type: 'boolean', + default: false, + description: 'Whether to use the bulk operation to delete the document/s', + }, + ], + }, // ---------------------------------------- // document: get @@ -644,6 +666,13 @@ export const documentFields: INodeProperties[] = [ }, }, options: [ + { + displayName: 'Bulk Create', + name: 'bulkOperation', + type: 'boolean', + default: false, + description: 'Whether to use the bulk operation to create the document/s', + }, { displayName: 'Pipeline ID', name: 'pipeline', @@ -802,6 +831,13 @@ export const documentFields: INodeProperties[] = [ }, }, options: [ + { + displayName: 'Bulk Update', + name: 'bulkOperation', + type: 'boolean', + default: false, + description: 'Whether to use the bulk operation to update the document/s', + }, { displayName: 'Refresh', name: 'refresh', From 89cd12bb6cc0b914e015401c0a2f583f3a971b1d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2024 13:17:18 +0300 Subject: [PATCH 16/19] :rocket: Release 1.54.0 (#10315) Co-authored-by: tomi --- CHANGELOG.md | 41 ++++++++++++++++++++++ package.json | 2 +- packages/@n8n/chat/package.json | 2 +- packages/@n8n/config/package.json | 2 +- packages/@n8n/nodes-langchain/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/design-system/package.json | 2 +- packages/editor-ui/package.json | 2 +- packages/node-dev/package.json | 2 +- packages/nodes-base/package.json | 2 +- packages/workflow/package.json | 2 +- 12 files changed, 52 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 986b85b0682ea..99120642fe416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,44 @@ +# [1.54.0](https://github.com/n8n-io/n8n/compare/n8n@1.53.0...n8n@1.54.0) (2024-08-07) + + +### Bug Fixes + +* **core:** Ensure OAuth token data is not stubbed in source control ([#10302](https://github.com/n8n-io/n8n/issues/10302)) ([98115e9](https://github.com/n8n-io/n8n/commit/98115e95df8289a8ec400a570a7f256382f8e286)) +* **core:** Fix expressions in webhook nodes(Form, Webhook) to access previous node's data ([#10247](https://github.com/n8n-io/n8n/issues/10247)) ([88a1701](https://github.com/n8n-io/n8n/commit/88a170176a3447e7f847e9cf145aeb867b1c5fcf)) +* **core:** Fix user telemetry bugs ([#10293](https://github.com/n8n-io/n8n/issues/10293)) ([42a0b59](https://github.com/n8n-io/n8n/commit/42a0b594d6ea2527c55a2aa9976c904cf70ecf92)) +* **core:** Make execution and its data creation atomic ([#10276](https://github.com/n8n-io/n8n/issues/10276)) ([ae50bb9](https://github.com/n8n-io/n8n/commit/ae50bb95a8e5bf1cdbf9483da54b84094b82e260)) +* **core:** Make OAuth1/OAuth2 callback not require auth ([#10263](https://github.com/n8n-io/n8n/issues/10263)) ([a8e2774](https://github.com/n8n-io/n8n/commit/a8e2774f5382e202556b5506c7788265786aa973)) +* **core:** Revert transactions until we remove the legacy sqlite driver ([#10299](https://github.com/n8n-io/n8n/issues/10299)) ([1eba7c3](https://github.com/n8n-io/n8n/commit/1eba7c3c763ac5b6b28c1c6fc43fc8c215249292)) +* **core:** Surface enterprise trial error message ([#10267](https://github.com/n8n-io/n8n/issues/10267)) ([432ac1d](https://github.com/n8n-io/n8n/commit/432ac1da59e173ce4c0f2abbc416743d9953ba70)) +* **core:** Upgrade tournament to address some XSS vulnerabilities ([#10277](https://github.com/n8n-io/n8n/issues/10277)) ([43ae159](https://github.com/n8n-io/n8n/commit/43ae159ea40c574f8e41bdfd221ab2bf3268eee7)) +* **core:** VM2 sandbox should not throw on `new Promise` ([#10298](https://github.com/n8n-io/n8n/issues/10298)) ([7e95f9e](https://github.com/n8n-io/n8n/commit/7e95f9e2e40a99871f1b6abcdacb39ac5f857332)) +* **core:** Webhook and form baseUrl missing ([#10290](https://github.com/n8n-io/n8n/issues/10290)) ([8131d66](https://github.com/n8n-io/n8n/commit/8131d66f8ca1b1da00597a12859ee4372148a0c9)) +* **editor:** Enable moving resources only if team projects are available by the license ([#10271](https://github.com/n8n-io/n8n/issues/10271)) ([42ba884](https://github.com/n8n-io/n8n/commit/42ba8841c401126c77158a53dc8fcbb45dfce8fd)) +* **editor:** Fix execution retry button ([#10275](https://github.com/n8n-io/n8n/issues/10275)) ([55f2ffe](https://github.com/n8n-io/n8n/commit/55f2ffe256c91a028cee95c3bbb37a093a1c0f81)) +* **editor:** Update design system Avatar component to show initials also when only firstName or lastName is given ([#10308](https://github.com/n8n-io/n8n/issues/10308)) ([46bbf09](https://github.com/n8n-io/n8n/commit/46bbf09beacad12472d91786b91d845fe2afb26d)) +* **editor:** Update tags filter/editor to not show non existing tag as a selectable option ([#10297](https://github.com/n8n-io/n8n/issues/10297)) ([557a76e](https://github.com/n8n-io/n8n/commit/557a76ec2326de72fb7a8b46fc4353f8fd9b591d)) +* **Invoice Ninja Node:** Fix payment types ([#10196](https://github.com/n8n-io/n8n/issues/10196)) ([c5acbb7](https://github.com/n8n-io/n8n/commit/c5acbb7ec0d24ec9b30c221fa3b2fb615fb9ec7f)) +* Loop node no input data shown ([#10224](https://github.com/n8n-io/n8n/issues/10224)) ([c8ee852](https://github.com/n8n-io/n8n/commit/c8ee852159207be0cfe2c3e0ee8e7b29d838aa35)) + + +### Features + +* **core:** Allow filtering executions and users by project in Public API ([#10250](https://github.com/n8n-io/n8n/issues/10250)) ([7056e50](https://github.com/n8n-io/n8n/commit/7056e50b006bda665f64ce6234c5c1967891c415)) +* **core:** Allow transferring credentials in Public API ([#10259](https://github.com/n8n-io/n8n/issues/10259)) ([07d7b24](https://github.com/n8n-io/n8n/commit/07d7b247f02a9d7185beca7817deb779a3d665dd)) +* **core:** Show sub-node error on the logs pane. Open logs pane on sub-node error ([#10248](https://github.com/n8n-io/n8n/issues/10248)) ([57d1c9a](https://github.com/n8n-io/n8n/commit/57d1c9a99e97308f2f1b8ae05ac3861a835e8e5a)) +* **core:** Support community packages in scaling-mode ([#10228](https://github.com/n8n-io/n8n/issues/10228)) ([88086a4](https://github.com/n8n-io/n8n/commit/88086a41ff5b804b35aa9d9503dc2d48836fe4ec)) +* **core:** Support create, delete, edit role for users in Public API ([#10279](https://github.com/n8n-io/n8n/issues/10279)) ([84efbd9](https://github.com/n8n-io/n8n/commit/84efbd9b9c51f536b21a4f969ab607d277bef692)) +* **core:** Support create, read, update, delete projects in Public API ([#10269](https://github.com/n8n-io/n8n/issues/10269)) ([489ce10](https://github.com/n8n-io/n8n/commit/489ce100634c3af678fb300e9a39d273042542e6)) +* **editor:** Auto-add LLM chain for new LLM nodes on empty canvas ([#10245](https://github.com/n8n-io/n8n/issues/10245)) ([06419d9](https://github.com/n8n-io/n8n/commit/06419d9483ae916e79aace6d8c17e265b419b15d)) +* **Elasticsearch Node:** Add bulk operations for Elasticsearch ([#9940](https://github.com/n8n-io/n8n/issues/9940)) ([bf8f848](https://github.com/n8n-io/n8n/commit/bf8f848645dfd31527713a55bd1fc93865327017)) +* **Lemlist Trigger Node:** Update Trigger events ([#10311](https://github.com/n8n-io/n8n/issues/10311)) ([15f10ec](https://github.com/n8n-io/n8n/commit/15f10ec325cb5eda0f952bed3a5f171dd91bc639)) +* **MongoDB Node:** Add projection to query options on Find ([#9972](https://github.com/n8n-io/n8n/issues/9972)) ([0a84e0d](https://github.com/n8n-io/n8n/commit/0a84e0d8b047669f5cf023c21383d01c929c5b4f)) +* **Postgres Chat Memory, Redis Chat Memory, Xata:** Add support for context window length ([#10203](https://github.com/n8n-io/n8n/issues/10203)) ([e3edeaa](https://github.com/n8n-io/n8n/commit/e3edeaa03526f041d15d1099ea91869e38a0decc)) +* **Stripe Trigger Node:** Add Stripe webhook descriptions based on the workflow ID and name ([#9956](https://github.com/n8n-io/n8n/issues/9956)) ([3433465](https://github.com/n8n-io/n8n/commit/34334651e0e6874736a437a894176bed4590e5a7)) +* **Webflow Node:** Update to use the v2 API ([#9996](https://github.com/n8n-io/n8n/issues/9996)) ([6d8323f](https://github.com/n8n-io/n8n/commit/6d8323fadea8af04483eb1a873df0cf3ccc2a891)) + + + # [1.53.0](https://github.com/n8n-io/n8n/compare/n8n@1.52.0...n8n@1.53.0) (2024-07-31) diff --git a/package.json b/package.json index e05eeb9845f1b..d4a208385b4cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.53.0", + "version": "1.54.0", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index 5ba8a1c39f201..f960f16783bb7 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.22.0", + "version": "0.23.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index a4abfa677adc1..e28090e49b9d2 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.3.0", + "version": "1.4.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 899f5d016ff4d..b5d73d1862501 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.53.0", + "version": "1.54.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index ca8f192e40152..fb6983f4b407a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.53.0", + "version": "1.54.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index dfcc23db99d92..1f857b47e5ea5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.53.0", + "version": "1.54.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 386a7e1e862c8..694aa546d54ee 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.43.0", + "version": "1.44.0", "main": "src/main.ts", "import": "src/main.ts", "scripts": { diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index caf156fa83c9e..51507fe73eaae 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.53.0", + "version": "1.54.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index d7b16155a1aa5..cd77936f1376d 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "1.53.0", + "version": "1.54.0", "description": "CLI to simplify n8n credentials/node development", "main": "dist/src/index", "types": "dist/src/index.d.ts", diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index d3c73d1f04ed0..4287724cf1076 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "1.53.0", + "version": "1.54.0", "description": "Base nodes of n8n", "main": "index.js", "scripts": { diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 77348f2a945e1..96ad730e0fbfa 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "1.52.0", + "version": "1.53.0", "description": "Workflow base code of n8n", "main": "dist/index.js", "module": "src/index.ts", From f744d7c100be68669d9a3efd0033dd371a3cfaf7 Mon Sep 17 00:00:00 2001 From: Ria Scholz <123465523+mariaremote@users.noreply.github.com> Date: Wed, 7 Aug 2024 12:48:14 +0200 Subject: [PATCH 17/19] feat(MySQL Node): Return decimal types as numbers (#10313) --- .../nodes/MySql/v2/actions/common.descriptions.ts | 10 ++++++++++ packages/nodes-base/nodes/MySql/v2/transport/index.ts | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/packages/nodes-base/nodes/MySql/v2/actions/common.descriptions.ts b/packages/nodes-base/nodes/MySql/v2/actions/common.descriptions.ts index 67a1eda87eb5f..12f8ffd2427ef 100644 --- a/packages/nodes-base/nodes/MySql/v2/actions/common.descriptions.ts +++ b/packages/nodes-base/nodes/MySql/v2/actions/common.descriptions.ts @@ -134,6 +134,16 @@ export const optionsCollection: INodeProperties = { show: { '/operation': ['select', 'executeQuery'] }, }, }, + { + displayName: 'Output Decimals as Numbers', + name: 'decimalNumbers', + type: 'boolean', + default: false, + description: 'Whether to output DECIMAL types as numbers instead of strings', + displayOptions: { + show: { '/operation': ['select', 'executeQuery'] }, + }, + }, { displayName: 'Priority', name: 'priority', diff --git a/packages/nodes-base/nodes/MySql/v2/transport/index.ts b/packages/nodes-base/nodes/MySql/v2/transport/index.ts index cbb373a52436e..f9e50fcb8f6e2 100644 --- a/packages/nodes-base/nodes/MySql/v2/transport/index.ts +++ b/packages/nodes-base/nodes/MySql/v2/transport/index.ts @@ -24,6 +24,7 @@ export async function createPool( password: credentials.password, multipleStatements: true, supportBigNumbers: true, + decimalNumbers: false, }; if (credentials.ssl) { @@ -55,6 +56,10 @@ export async function createPool( connectionOptions.bigNumberStrings = true; } + if (options?.decimalNumbers === true) { + connectionOptions.decimalNumbers = true; + } + if (!credentials.sshTunnel) { return mysql2.createPool(connectionOptions); } else { From ee968b71635a120dd85b2e88b5508ee059d26204 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Wed, 7 Aug 2024 12:52:42 +0200 Subject: [PATCH 18/19] fix(editor): Remove body padding from storybook previews (no-changelog) (#10317) --- packages/design-system/.storybook/storybook.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/design-system/.storybook/storybook.scss b/packages/design-system/.storybook/storybook.scss index 86a675f90cff2..16aaf700f403e 100644 --- a/packages/design-system/.storybook/storybook.scss +++ b/packages/design-system/.storybook/storybook.scss @@ -15,3 +15,7 @@ #storybook-root > * { margin: var(--spacing-5xs); } + +body { + padding: 0 !important; +} From e31d017bddac88c46a462811cb74a782bb6ffb61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 7 Aug 2024 13:50:46 +0200 Subject: [PATCH 19/19] refactor(core): Centralize scaling mode (no-changelog) (#9835) --- packages/cli/src/Interfaces.ts | 4 +- packages/cli/src/Queue.ts | 166 ----------- packages/cli/src/Server.ts | 4 +- packages/cli/src/WorkflowRunner.ts | 23 +- packages/cli/src/commands/webhook.ts | 4 +- packages/cli/src/commands/worker.ts | 257 ++---------------- .../cli/src/errors/max-stalled-count.error.ts | 2 +- .../__tests__/execution.service.test.ts | 24 +- .../executions/execution-recovery.service.ts | 6 +- .../cli/src/executions/execution.service.ts | 10 +- packages/cli/src/scaling/constants.ts | 3 + packages/cli/src/scaling/job-processor.ts | 182 +++++++++++++ packages/cli/src/scaling/scaling.service.ts | 213 +++++++++++++++ packages/cli/src/scaling/types.ts | 55 ++++ .../services/orchestration/worker/types.ts | 5 +- packages/cli/src/webhooks/WebhookHelpers.ts | 13 - .../integration/commands/worker.cmd.test.ts | 11 +- 17 files changed, 529 insertions(+), 453 deletions(-) delete mode 100644 packages/cli/src/Queue.ts create mode 100644 packages/cli/src/scaling/constants.ts create mode 100644 packages/cli/src/scaling/job-processor.ts create mode 100644 packages/cli/src/scaling/scaling.service.ts create mode 100644 packages/cli/src/scaling/types.ts diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 9a1fdc367f4b5..73b1e99ec6559 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -42,7 +42,7 @@ import type { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { ExternalHooks } from './ExternalHooks'; import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants'; import type { WorkflowWithSharingsAndCredentials } from './workflows/workflows.types'; -import type { WorkerJobStatusSummary } from './services/orchestration/worker/types'; +import type { RunningJobSummary } from './scaling/types'; import type { Scope } from '@n8n/permissions'; export interface ICredentialsTypeData { @@ -420,7 +420,7 @@ export interface IPushDataWorkerStatusMessage { export interface IPushDataWorkerStatusPayload { workerId: string; - runningJobsSummary: WorkerJobStatusSummary[]; + runningJobsSummary: RunningJobSummary[]; freeMem: number; totalMem: number; uptime: number; diff --git a/packages/cli/src/Queue.ts b/packages/cli/src/Queue.ts deleted file mode 100644 index 11cfed839bee7..0000000000000 --- a/packages/cli/src/Queue.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type Bull from 'bull'; -import Container, { Service } from 'typedi'; -import { - ApplicationError, - BINARY_ENCODING, - type IDataObject, - type ExecutionError, - type IExecuteResponsePromiseData, -} from 'n8n-workflow'; -import { ActiveExecutions } from '@/ActiveExecutions'; -import config from '@/config'; -import { OnShutdown } from './decorators/OnShutdown'; -import { HIGHEST_SHUTDOWN_PRIORITY } from './constants'; - -export type JobId = Bull.JobId; -export type Job = Bull.Job; -export type JobQueue = Bull.Queue; - -export interface JobData { - executionId: string; - loadStaticData: boolean; -} - -export interface JobResponse { - success: boolean; - error?: ExecutionError; -} - -export interface WebhookResponse { - executionId: string; - response: IExecuteResponsePromiseData; -} - -@Service() -export class Queue { - private jobQueue: JobQueue; - - /** - * The number of jobs a single server can process concurrently - * Any worker that wants to process executions must first set this to a non-zero value - */ - private concurrency = 0; - - setConcurrency(concurrency: number) { - this.concurrency = concurrency; - // This sets the max event listeners on the jobQueue EventEmitter to prevent the logs getting flooded with MaxListenersExceededWarning - // see: https://github.com/OptimalBits/bull/blob/develop/lib/job.js#L497-L521 - this.jobQueue.setMaxListeners( - 4 + // `close` - 2 + // `error` - 2 + // `global:progress` - concurrency * 2, // 2 global events for every call to `job.finished()` - ); - } - - constructor(private activeExecutions: ActiveExecutions) {} - - async init() { - const { default: Bull } = await import('bull'); - const { RedisClientService } = await import('@/services/redis/redis-client.service'); - - const redisClientService = Container.get(RedisClientService); - - const bullPrefix = config.getEnv('queue.bull.prefix'); - const prefix = redisClientService.toValidPrefix(bullPrefix); - - this.jobQueue = new Bull('jobs', { - prefix, - settings: config.get('queue.bull.settings'), - createClient: (type) => redisClientService.createClient({ type: `${type}(bull)` }), - }); - - this.jobQueue.on('global:progress', (_jobId, progress: WebhookResponse) => { - this.activeExecutions.resolveResponsePromise( - progress.executionId, - this.decodeWebhookResponse(progress.response), - ); - }); - } - - async findRunningJobBy({ executionId }: { executionId: string }) { - const activeOrWaitingJobs = await this.getJobs(['active', 'waiting']); - - return activeOrWaitingJobs.find(({ data }) => data.executionId === executionId) ?? null; - } - - decodeWebhookResponse(response: IExecuteResponsePromiseData): IExecuteResponsePromiseData { - if ( - typeof response === 'object' && - typeof response.body === 'object' && - (response.body as IDataObject)['__@N8nEncodedBuffer@__'] - ) { - response.body = Buffer.from( - (response.body as IDataObject)['__@N8nEncodedBuffer@__'] as string, - BINARY_ENCODING, - ); - } - - return response; - } - - async add(jobData: JobData, jobOptions: object): Promise { - return await this.jobQueue.add(jobData, jobOptions); - } - - async getJob(jobId: JobId): Promise { - return await this.jobQueue.getJob(jobId); - } - - async getJobs(jobTypes: Bull.JobStatus[]): Promise { - return await this.jobQueue.getJobs(jobTypes); - } - - /** - * Get IDs of executions that are currently in progress in the queue. - */ - async getInProgressExecutionIds() { - const inProgressJobs = await this.getJobs(['active', 'waiting']); - - return new Set(inProgressJobs.map((job) => job.data.executionId)); - } - - async process(fn: Bull.ProcessCallbackFunction): Promise { - return await this.jobQueue.process(this.concurrency, fn); - } - - async ping(): Promise { - return await this.jobQueue.client.ping(); - } - - @OnShutdown(HIGHEST_SHUTDOWN_PRIORITY) - // Stop accepting new jobs, `doNotWaitActive` allows reporting progress - async pause(): Promise { - return await this.jobQueue?.pause(true, true); - } - - getBullObjectInstance(): JobQueue { - if (this.jobQueue === undefined) { - // if queue is not initialized yet throw an error, since we do not want to hand around an undefined queue - throw new ApplicationError('Queue is not initialized yet!'); - } - return this.jobQueue; - } - - /** - * - * @param job A Job instance - * @returns boolean true if we were able to securely stop the job - */ - async stopJob(job: Job): Promise { - if (await job.isActive()) { - // Job is already running so tell it to stop - await job.progress(-1); - return true; - } - // Job did not get started yet so remove from queue - try { - await job.remove(); - return true; - } catch (e) { - await job.progress(-1); - } - - return false; - } -} diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 0eb2040e7f1bb..777472b99fa42 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -211,8 +211,8 @@ export class Server extends AbstractServer { setupPushHandler(restEndpoint, app); if (config.getEnv('executions.mode') === 'queue') { - const { Queue } = await import('@/Queue'); - await Container.get(Queue).init(); + const { ScalingService } = await import('@/scaling/scaling.service'); + await Container.get(ScalingService).setupQueue(); } await handleMfaDisable(); diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 2f7976a073b30..ac5f586b0ad43 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -28,8 +28,8 @@ import { ExecutionRepository } from '@db/repositories/execution.repository'; import { ExternalHooks } from '@/ExternalHooks'; import type { IExecutionResponse, IWorkflowExecutionDataProcess } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; -import type { Job, JobData, JobResponse } from '@/Queue'; -import { Queue } from '@/Queue'; +import type { Job, JobData, JobResult } from '@/scaling/types'; +import { ScalingService } from '@/scaling/scaling.service'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { generateFailedExecutionFromError } from '@/WorkflowHelpers'; @@ -40,7 +40,7 @@ import { EventService } from './events/event.service'; @Service() export class WorkflowRunner { - private jobQueue: Queue; + private readonly scalingService: ScalingService; private executionsMode = config.getEnv('executions.mode'); @@ -55,7 +55,7 @@ export class WorkflowRunner { private readonly eventService: EventService, ) { if (this.executionsMode === 'queue') { - this.jobQueue = Container.get(Queue); + this.scalingService = Container.get(ScalingService); } } @@ -375,9 +375,7 @@ export class WorkflowRunner { let job: Job; let hooks: WorkflowHooks; try { - job = await this.jobQueue.add(jobData, jobOptions); - - this.logger.info(`Started with job ID: ${job.id.toString()} (Execution ID: ${executionId})`); + job = await this.scalingService.addJob(jobData, jobOptions); hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerMain( data.executionMode, @@ -406,8 +404,7 @@ export class WorkflowRunner { async (resolve, reject, onCancel) => { onCancel.shouldReject = false; onCancel(async () => { - const queue = Container.get(Queue); - await queue.stopJob(job); + await Container.get(ScalingService).stopJob(job); // We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the // "workflowExecuteAfter" which we require. @@ -424,11 +421,11 @@ export class WorkflowRunner { reject(error); }); - const jobData: Promise = job.finished(); + const jobData: Promise = job.finished(); const queueRecoveryInterval = config.getEnv('queue.bull.queueRecoveryInterval'); - const racingPromises: Array> = [jobData]; + const racingPromises: Array> = [jobData]; let clearWatchdogInterval; if (queueRecoveryInterval > 0) { @@ -446,9 +443,9 @@ export class WorkflowRunner { ************************************************ */ let watchDogInterval: NodeJS.Timeout | undefined; - const watchDog: Promise = new Promise((res) => { + const watchDog: Promise = new Promise((res) => { watchDogInterval = setInterval(async () => { - const currentJob = await this.jobQueue.getJob(job.id); + const currentJob = await this.scalingService.getJob(job.id); // When null means job is finished (not found in queue) if (currentJob === null) { // Mimic worker's success message diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index 2c8532ec76342..505e2e216170d 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -4,8 +4,8 @@ import { ApplicationError } from 'n8n-workflow'; import config from '@/config'; import { ActiveExecutions } from '@/ActiveExecutions'; +import { ScalingService } from '@/scaling/scaling.service'; import { WebhookServer } from '@/webhooks/WebhookServer'; -import { Queue } from '@/Queue'; import { BaseCommand } from './BaseCommand'; import { OrchestrationWebhookService } from '@/services/orchestration/webhook/orchestration.webhook.service'; @@ -96,7 +96,7 @@ export class Webhook extends BaseCommand { ); } - await Container.get(Queue).init(); + await Container.get(ScalingService).setupQueue(); await this.server.start(); this.logger.debug(`Webhook listener ID: ${this.server.uniqueInstanceId}`); this.logger.info('Webhook listener waiting for requests.'); diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 5681b417273d6..18f039029c020 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -2,21 +2,13 @@ import { Container } from 'typedi'; import { Flags, type Config } from '@oclif/core'; import express from 'express'; import http from 'http'; -import type PCancelable from 'p-cancelable'; -import { WorkflowExecute } from 'n8n-core'; -import type { ExecutionStatus, IExecuteResponsePromiseData, INodeTypes, IRun } from 'n8n-workflow'; -import { Workflow, sleep, ApplicationError } from 'n8n-workflow'; +import { sleep, ApplicationError } from 'n8n-workflow'; import * as Db from '@/Db'; import * as ResponseHelper from '@/ResponseHelper'; -import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; -import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import config from '@/config'; -import type { Job, JobId, JobResponse, WebhookResponse } from '@/Queue'; -import { Queue } from '@/Queue'; +import { ScalingService } from '@/scaling/scaling.service'; import { N8N_VERSION, inTest } from '@/constants'; -import { ExecutionRepository } from '@db/repositories/execution.repository'; -import { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { ICredentialsOverwrite } from '@/Interfaces'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { rawBodyReader, bodyParser } from '@/middlewares'; @@ -25,10 +17,9 @@ import type { RedisServicePubSubSubscriber } from '@/services/redis/RedisService import { EventMessageGeneric } from '@/eventbus/EventMessageClasses/EventMessageGeneric'; import { OrchestrationHandlerWorkerService } from '@/services/orchestration/worker/orchestration.handler.worker.service'; import { OrchestrationWorkerService } from '@/services/orchestration/worker/orchestration.worker.service'; -import type { WorkerJobStatusSummary } from '@/services/orchestration/worker/types'; import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; import { BaseCommand } from './BaseCommand'; -import { MaxStalledCountError } from '@/errors/max-stalled-count.error'; +import { JobProcessor } from '@/scaling/job-processor'; import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay'; export class Worker extends BaseCommand { @@ -44,15 +35,17 @@ export class Worker extends BaseCommand { }), }; - static runningJobs: { - [key: string]: PCancelable; - } = {}; + /** + * How many jobs this worker may run concurrently. + * + * Taken from env var `N8N_CONCURRENCY_PRODUCTION_LIMIT` if set to a value + * other than -1, else taken from `--concurrency` flag. + */ + concurrency: number; - static runningJobsSummary: { - [jobId: string]: WorkerJobStatusSummary; - } = {}; + scalingService: ScalingService; - static jobQueue: Queue; + jobProcessor: JobProcessor; redisSubscriber: RedisServicePubSubSubscriber; @@ -73,12 +66,12 @@ export class Worker extends BaseCommand { // Wait for active workflow executions to finish let count = 0; - while (Object.keys(Worker.runningJobs).length !== 0) { + while (this.jobProcessor.getRunningJobIds().length !== 0) { if (count++ % 4 === 0) { const waitLeft = Math.ceil((hardStopTimeMs - Date.now()) / 1000); this.logger.info( `Waiting for ${ - Object.keys(Worker.runningJobs).length + Object.keys(this.jobProcessor.getRunningJobIds()).length } active executions to finish... (max wait ${waitLeft} more seconds)`, ); } @@ -92,143 +85,6 @@ export class Worker extends BaseCommand { await this.exitSuccessFully(); } - async runJob(job: Job, nodeTypes: INodeTypes): Promise { - const { executionId, loadStaticData } = job.data; - const executionRepository = Container.get(ExecutionRepository); - const fullExecutionData = await executionRepository.findSingleExecution(executionId, { - includeData: true, - unflattenData: true, - }); - - if (!fullExecutionData) { - this.logger.error( - `Worker failed to find data of execution "${executionId}" in database. Cannot continue.`, - { executionId }, - ); - throw new ApplicationError( - 'Unable to find data of execution in database. Aborting execution.', - { extra: { executionId } }, - ); - } - const workflowId = fullExecutionData.workflowData.id; - - this.logger.info( - `Start job: ${job.id} (Workflow ID: ${workflowId} | Execution: ${executionId})`, - ); - await executionRepository.updateStatus(executionId, 'running'); - - let { staticData } = fullExecutionData.workflowData; - if (loadStaticData) { - const workflowData = await Container.get(WorkflowRepository).findOne({ - select: ['id', 'staticData'], - where: { - id: workflowId, - }, - }); - if (workflowData === null) { - this.logger.error( - 'Worker execution failed because workflow could not be found in database.', - { workflowId, executionId }, - ); - throw new ApplicationError('Workflow could not be found', { extra: { workflowId } }); - } - staticData = workflowData.staticData; - } - - const workflowSettings = fullExecutionData.workflowData.settings ?? {}; - - let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); // initialize with default - - let executionTimeoutTimestamp: number | undefined; - if (workflowTimeout > 0) { - workflowTimeout = Math.min(workflowTimeout, config.getEnv('executions.maxTimeout')); - executionTimeoutTimestamp = Date.now() + workflowTimeout * 1000; - } - - const workflow = new Workflow({ - id: workflowId, - name: fullExecutionData.workflowData.name, - nodes: fullExecutionData.workflowData.nodes, - connections: fullExecutionData.workflowData.connections, - active: fullExecutionData.workflowData.active, - nodeTypes, - staticData, - settings: fullExecutionData.workflowData.settings, - }); - - const additionalData = await WorkflowExecuteAdditionalData.getBase( - undefined, - undefined, - executionTimeoutTimestamp, - ); - additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( - fullExecutionData.mode, - job.data.executionId, - fullExecutionData.workflowData, - { - retryOf: fullExecutionData.retryOf as string, - }, - ); - - additionalData.hooks.hookFunctions.sendResponse = [ - async (response: IExecuteResponsePromiseData): Promise => { - const progress: WebhookResponse = { - executionId, - response: WebhookHelpers.encodeWebhookResponse(response), - }; - await job.progress(progress); - }, - ]; - - additionalData.executionId = executionId; - - additionalData.setExecutionStatus = (status: ExecutionStatus) => { - // Can't set the status directly in the queued worker, but it will happen in InternalHook.onWorkflowPostExecute - this.logger.debug(`Queued worker execution status for ${executionId} is "${status}"`); - }; - - let workflowExecute: WorkflowExecute; - let workflowRun: PCancelable; - if (fullExecutionData.data !== undefined) { - workflowExecute = new WorkflowExecute( - additionalData, - fullExecutionData.mode, - fullExecutionData.data, - ); - workflowRun = workflowExecute.processRunExecutionData(workflow); - } else { - // Execute all nodes - // Can execute without webhook so go on - workflowExecute = new WorkflowExecute(additionalData, fullExecutionData.mode); - workflowRun = workflowExecute.run(workflow); - } - - Worker.runningJobs[job.id] = workflowRun; - Worker.runningJobsSummary[job.id] = { - jobId: job.id.toString(), - executionId, - workflowId: fullExecutionData.workflowId ?? '', - workflowName: fullExecutionData.workflowData.name, - mode: fullExecutionData.mode, - startedAt: fullExecutionData.startedAt, - retryOf: fullExecutionData.retryOf ?? '', - status: fullExecutionData.status, - }; - - // Wait till the execution is finished - await workflowRun; - - delete Worker.runningJobs[job.id]; - delete Worker.runningJobsSummary[job.id]; - - // do NOT call workflowExecuteAfter hook here, since it is being called from processSuccessExecution() - // already! - - return { - success: true, - }; - } - constructor(argv: string[], cmdConfig: Config) { super(argv, cmdConfig); @@ -256,6 +112,7 @@ export class Worker extends BaseCommand { this.logger.debug('Starting n8n worker...'); this.logger.debug(`Queue mode id: ${this.queueModeId}`); + await this.setConcurrency(); await super.init(); await this.initLicense(); @@ -268,8 +125,7 @@ export class Worker extends BaseCommand { this.logger.debug('External secrets init complete'); await this.initEventBus(); this.logger.debug('Event bus init complete'); - await this.initQueue(); - this.logger.debug('Queue init complete'); + await this.initScalingService(); await this.initOrchestration(); this.logger.debug('Orchestration init complete'); @@ -301,80 +157,27 @@ export class Worker extends BaseCommand { await Container.get(OrchestrationHandlerWorkerService).initWithOptions({ queueModeId: this.queueModeId, redisPublisher: Container.get(OrchestrationWorkerService).redisPublisher, - getRunningJobIds: () => Object.keys(Worker.runningJobs), - getRunningJobsSummary: () => Object.values(Worker.runningJobsSummary), + getRunningJobIds: () => this.jobProcessor.getRunningJobIds(), + getRunningJobsSummary: () => this.jobProcessor.getRunningJobsSummary(), }); } - async initQueue() { + async setConcurrency() { const { flags } = await this.parse(Worker); - const redisConnectionTimeoutLimit = config.getEnv('queue.bull.redis.timeoutThreshold'); - - this.logger.debug( - `Opening Redis connection to listen to messages with timeout ${redisConnectionTimeoutLimit}`, - ); - - Worker.jobQueue = Container.get(Queue); - await Worker.jobQueue.init(); - this.logger.debug('Queue singleton ready'); - const envConcurrency = config.getEnv('executions.concurrency.productionLimit'); - const concurrency = envConcurrency !== -1 ? envConcurrency : flags.concurrency; - Worker.jobQueue.setConcurrency(concurrency); - void Worker.jobQueue.process(async (job) => await this.runJob(job, this.nodeTypes)); + this.concurrency = envConcurrency !== -1 ? envConcurrency : flags.concurrency; + } - Worker.jobQueue.getBullObjectInstance().on('global:progress', (jobId: JobId, progress) => { - // Progress of a job got updated which does get used - // to communicate that a job got canceled. + async initScalingService() { + this.scalingService = Container.get(ScalingService); - if (progress === -1) { - // Job has to get canceled - if (Worker.runningJobs[jobId] !== undefined) { - // Job is processed by current worker so cancel - Worker.runningJobs[jobId].cancel(); - delete Worker.runningJobs[jobId]; - } - } - }); + await this.scalingService.setupQueue(); - let lastTimer = 0; - let cumulativeTimeout = 0; - Worker.jobQueue.getBullObjectInstance().on('error', (error: Error) => { - if (error.toString().includes('ECONNREFUSED')) { - const now = Date.now(); - if (now - lastTimer > 30000) { - // Means we had no timeout at all or last timeout was temporary and we recovered - lastTimer = now; - cumulativeTimeout = 0; - } else { - cumulativeTimeout += now - lastTimer; - lastTimer = now; - if (cumulativeTimeout > redisConnectionTimeoutLimit) { - this.logger.error( - `Unable to connect to Redis after ${redisConnectionTimeoutLimit}. Exiting process.`, - ); - process.exit(1); - } - } - this.logger.warn('Redis unavailable - trying to reconnect...'); - } else if (error.toString().includes('Error initializing Lua scripts')) { - // This is a non-recoverable error - // Happens when worker starts and Redis is unavailable - // Even if Redis comes back online, worker will be zombie - this.logger.error('Error initializing worker.'); - process.exit(2); - } else { - this.logger.error('Error from queue: ', error); - - if (error.message.includes('job stalled more than maxStalledCount')) { - throw new MaxStalledCountError(error); - } + this.scalingService.setupWorker(this.concurrency); - throw error; - } - }); + this.jobProcessor = Container.get(JobProcessor); } async setupHealthMonitor() { @@ -410,7 +213,7 @@ export class Worker extends BaseCommand { // if it loses the connection to redis try { // Redis ping - await Worker.jobQueue.ping(); + await this.scalingService.pingQueue(); } catch (e) { this.logger.error('No Redis connection!', e as Error); const error = new ServiceUnavailableError('No Redis connection!'); @@ -476,18 +279,16 @@ export class Worker extends BaseCommand { } async run() { - const { flags } = await this.parse(Worker); - this.logger.info('\nn8n worker is now ready'); this.logger.info(` * Version: ${N8N_VERSION}`); - this.logger.info(` * Concurrency: ${flags.concurrency}`); + this.logger.info(` * Concurrency: ${this.concurrency}`); this.logger.info(''); if (config.getEnv('queue.health.active')) { await this.setupHealthMonitor(); } - if (process.stdout.isTTY) { + if (!inTest && process.stdout.isTTY) { process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); diff --git a/packages/cli/src/errors/max-stalled-count.error.ts b/packages/cli/src/errors/max-stalled-count.error.ts index 6715de0ade837..653ca18eacac7 100644 --- a/packages/cli/src/errors/max-stalled-count.error.ts +++ b/packages/cli/src/errors/max-stalled-count.error.ts @@ -1,7 +1,7 @@ import { ApplicationError } from 'n8n-workflow'; /** - * See https://github.com/OptimalBits/bull/blob/60fa88f08637f0325639988a3f054880a04ce402/docs/README.md?plain=1#L133 + * @docs https://docs.bullmq.io/guide/workers/stalled-jobs */ export class MaxStalledCountError extends ApplicationError { constructor(cause: Error) { diff --git a/packages/cli/src/executions/__tests__/execution.service.test.ts b/packages/cli/src/executions/__tests__/execution.service.test.ts index 04c266a4d071e..879ee707cdc8d 100644 --- a/packages/cli/src/executions/__tests__/execution.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution.service.test.ts @@ -6,14 +6,15 @@ import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.err import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error'; import type { ActiveExecutions } from '@/ActiveExecutions'; import type { IExecutionResponse } from '@/Interfaces'; -import type { Job, Queue } from '@/Queue'; +import type { ScalingService } from '@/scaling/scaling.service'; import type { WaitTracker } from '@/WaitTracker'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { ExecutionRequest } from '@/executions/execution.types'; import type { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; +import type { Job } from '@/scaling/types'; describe('ExecutionService', () => { - const queue = mock(); + const scalingService = mock(); const activeExecutions = mock(); const executionRepository = mock(); const waitTracker = mock(); @@ -22,7 +23,7 @@ describe('ExecutionService', () => { const executionService = new ExecutionService( mock(), mock(), - queue, + scalingService, activeExecutions, executionRepository, mock(), @@ -211,7 +212,7 @@ describe('ExecutionService', () => { expect(concurrencyControl.remove).not.toHaveBeenCalled(); expect(waitTracker.stopExecution).not.toHaveBeenCalled(); - expect(queue.stopJob).not.toHaveBeenCalled(); + expect(scalingService.stopJob).not.toHaveBeenCalled(); }); }); @@ -224,7 +225,8 @@ describe('ExecutionService', () => { const execution = mock({ id: '123', status: 'running' }); executionRepository.findSingleExecution.mockResolvedValue(execution); waitTracker.has.mockReturnValue(false); - queue.findRunningJobBy.mockResolvedValue(mock()); + const job = mock({ data: { executionId: '123' } }); + scalingService.findJobsByStatus.mockResolvedValue([job]); executionRepository.stopDuringRun.mockResolvedValue(mock()); /** @@ -237,8 +239,8 @@ describe('ExecutionService', () => { */ expect(waitTracker.stopExecution).not.toHaveBeenCalled(); expect(activeExecutions.stopExecution).toHaveBeenCalled(); - expect(queue.findRunningJobBy).toBeCalledWith({ executionId: execution.id }); - expect(queue.stopJob).toHaveBeenCalled(); + expect(scalingService.findJobsByStatus).toHaveBeenCalled(); + expect(scalingService.stopJob).toHaveBeenCalled(); expect(executionRepository.stopDuringRun).toHaveBeenCalled(); }); @@ -250,7 +252,8 @@ describe('ExecutionService', () => { const execution = mock({ id: '123', status: 'waiting' }); executionRepository.findSingleExecution.mockResolvedValue(execution); waitTracker.has.mockReturnValue(true); - queue.findRunningJobBy.mockResolvedValue(mock()); + const job = mock({ data: { executionId: '123' } }); + scalingService.findJobsByStatus.mockResolvedValue([job]); executionRepository.stopDuringRun.mockResolvedValue(mock()); /** @@ -262,9 +265,8 @@ describe('ExecutionService', () => { * Assert */ expect(waitTracker.stopExecution).toHaveBeenCalledWith(execution.id); - expect(activeExecutions.stopExecution).toHaveBeenCalled(); - expect(queue.findRunningJobBy).toBeCalledWith({ executionId: execution.id }); - expect(queue.stopJob).toHaveBeenCalled(); + expect(scalingService.findJobsByStatus).toHaveBeenCalled(); + expect(scalingService.stopJob).toHaveBeenCalled(); expect(executionRepository.stopDuringRun).toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index 435145545ab16..d691df1b3ce36 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -135,9 +135,11 @@ export class ExecutionRecoveryService { return waitMs; } - const { Queue } = await import('@/Queue'); + const { ScalingService } = await import('@/scaling/scaling.service'); - const queuedIds = await Container.get(Queue).getInProgressExecutionIds(); + const runningJobs = await Container.get(ScalingService).findJobsByStatus(['active', 'waiting']); + + const queuedIds = new Set(runningJobs.map((job) => job.data.executionId)); if (queuedIds.size === 0) { this.logger.debug('[Recovery] Completed queue recovery check, no dangling executions'); diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index 0823bde36fe7a..849d06c968c7e 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -24,7 +24,7 @@ import type { IWorkflowExecutionDataProcess, } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; -import { Queue } from '@/Queue'; +import { ScalingService } from '@/scaling/scaling.service'; import type { ExecutionRequest, ExecutionSummaries, StopResult } from './execution.types'; import { WorkflowRunner } from '@/WorkflowRunner'; import type { IGetExecutionsQueryFilter } from '@db/repositories/execution.repository'; @@ -85,7 +85,7 @@ export class ExecutionService { constructor( private readonly globalConfig: GlobalConfig, private readonly logger: Logger, - private readonly queue: Queue, + private readonly scalingService: ScalingService, private readonly activeExecutions: ActiveExecutions, private readonly executionRepository: ExecutionRepository, private readonly workflowRepository: WorkflowRepository, @@ -471,10 +471,12 @@ export class ExecutionService { this.waitTracker.stopExecution(execution.id); } - const job = await this.queue.findRunningJobBy({ executionId: execution.id }); + const jobs = await this.scalingService.findJobsByStatus(['active', 'waiting']); + + const job = jobs.find(({ data }) => data.executionId === execution.id); if (job) { - await this.queue.stopJob(job); + await this.scalingService.stopJob(job); } else { this.logger.debug('Job to stop not in queue', { executionId: execution.id }); } diff --git a/packages/cli/src/scaling/constants.ts b/packages/cli/src/scaling/constants.ts new file mode 100644 index 0000000000000..8ef5f716b17aa --- /dev/null +++ b/packages/cli/src/scaling/constants.ts @@ -0,0 +1,3 @@ +export const QUEUE_NAME = 'jobs'; + +export const JOB_TYPE_NAME = 'job'; diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts new file mode 100644 index 0000000000000..6057a1937db76 --- /dev/null +++ b/packages/cli/src/scaling/job-processor.ts @@ -0,0 +1,182 @@ +import { Service } from 'typedi'; +import { BINARY_ENCODING, ApplicationError, Workflow } from 'n8n-workflow'; +import { WorkflowExecute } from 'n8n-core'; +import { Logger } from '@/Logger'; +import config from '@/config'; +import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; +import { NodeTypes } from '@/NodeTypes'; +import type { ExecutionStatus, IExecuteResponsePromiseData, IRun } from 'n8n-workflow'; +import type { Job, JobId, JobResult, RunningJob, RunningJobSummary } from './types'; +import type PCancelable from 'p-cancelable'; + +/** + * Responsible for processing jobs from the queue, i.e. running enqueued executions. + */ +@Service() +export class JobProcessor { + private readonly runningJobs: { [jobId: JobId]: RunningJob } = {}; + + constructor( + private readonly logger: Logger, + private readonly executionRepository: ExecutionRepository, + private readonly workflowRepository: WorkflowRepository, + private readonly nodeTypes: NodeTypes, + ) {} + + async processJob(job: Job): Promise { + const { executionId, loadStaticData } = job.data; + + const execution = await this.executionRepository.findSingleExecution(executionId, { + includeData: true, + unflattenData: true, + }); + + if (!execution) { + this.logger.error('[JobProcessor] Failed to find execution data', { executionId }); + throw new ApplicationError('Failed to find execution data. Aborting execution.', { + extra: { executionId }, + }); + } + + const workflowId = execution.workflowData.id; + + this.logger.info(`[JobProcessor] Starting job ${job.id} (execution ${executionId})`); + + await this.executionRepository.updateStatus(executionId, 'running'); + + let { staticData } = execution.workflowData; + + if (loadStaticData) { + const workflowData = await this.workflowRepository.findOne({ + select: ['id', 'staticData'], + where: { id: workflowId }, + }); + + if (workflowData === null) { + this.logger.error('[JobProcessor] Failed to find workflow', { workflowId, executionId }); + throw new ApplicationError('Failed to find workflow', { extra: { workflowId } }); + } + + staticData = workflowData.staticData; + } + + const workflowSettings = execution.workflowData.settings ?? {}; + + let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); + + let executionTimeoutTimestamp: number | undefined; + + if (workflowTimeout > 0) { + workflowTimeout = Math.min(workflowTimeout, config.getEnv('executions.maxTimeout')); + executionTimeoutTimestamp = Date.now() + workflowTimeout * 1000; + } + + const workflow = new Workflow({ + id: workflowId, + name: execution.workflowData.name, + nodes: execution.workflowData.nodes, + connections: execution.workflowData.connections, + active: execution.workflowData.active, + nodeTypes: this.nodeTypes, + staticData, + settings: execution.workflowData.settings, + }); + + const additionalData = await WorkflowExecuteAdditionalData.getBase( + undefined, + undefined, + executionTimeoutTimestamp, + ); + + additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( + execution.mode, + job.data.executionId, + execution.workflowData, + { retryOf: execution.retryOf as string }, + ); + + additionalData.hooks.hookFunctions.sendResponse = [ + async (response: IExecuteResponsePromiseData): Promise => { + await job.progress({ + kind: 'respond-to-webhook', + executionId, + response: this.encodeWebhookResponse(response), + }); + }, + ]; + + additionalData.executionId = executionId; + + additionalData.setExecutionStatus = (status: ExecutionStatus) => { + // Can't set the status directly in the queued worker, but it will happen in InternalHook.onWorkflowPostExecute + this.logger.debug( + `[JobProcessor] Queued worker execution status for ${executionId} is "${status}"`, + ); + }; + + let workflowExecute: WorkflowExecute; + let workflowRun: PCancelable; + if (execution.data !== undefined) { + workflowExecute = new WorkflowExecute(additionalData, execution.mode, execution.data); + workflowRun = workflowExecute.processRunExecutionData(workflow); + } else { + // Execute all nodes + // Can execute without webhook so go on + workflowExecute = new WorkflowExecute(additionalData, execution.mode); + workflowRun = workflowExecute.run(workflow); + } + + const runningJob: RunningJob = { + run: workflowRun, + executionId, + workflowId: execution.workflowId, + workflowName: execution.workflowData.name, + mode: execution.mode, + startedAt: execution.startedAt, + retryOf: execution.retryOf ?? '', + status: execution.status, + }; + + this.runningJobs[job.id] = runningJob; + + await workflowRun; + + delete this.runningJobs[job.id]; + + this.logger.debug('[JobProcessor] Job finished running', { jobId: job.id, executionId }); + + /** + * @important Do NOT call `workflowExecuteAfter` hook here. + * It is being called from processSuccessExecution() already. + */ + + return { success: true }; + } + + stopJob(jobId: JobId) { + this.runningJobs[jobId]?.run.cancel(); + delete this.runningJobs[jobId]; + } + + getRunningJobIds(): JobId[] { + return Object.keys(this.runningJobs); + } + + getRunningJobsSummary(): RunningJobSummary[] { + return Object.values(this.runningJobs).map(({ run, ...summary }) => summary); + } + + private encodeWebhookResponse( + response: IExecuteResponsePromiseData, + ): IExecuteResponsePromiseData { + if (typeof response === 'object' && Buffer.isBuffer(response.body)) { + response.body = { + '__@N8nEncodedBuffer@__': response.body.toString(BINARY_ENCODING), + }; + } + + return response; + } +} diff --git a/packages/cli/src/scaling/scaling.service.ts b/packages/cli/src/scaling/scaling.service.ts new file mode 100644 index 0000000000000..c52a82e8a0da6 --- /dev/null +++ b/packages/cli/src/scaling/scaling.service.ts @@ -0,0 +1,213 @@ +import Container, { Service } from 'typedi'; +import { ApplicationError, BINARY_ENCODING } from 'n8n-workflow'; +import { ActiveExecutions } from '@/ActiveExecutions'; +import config from '@/config'; +import { Logger } from '@/Logger'; +import { MaxStalledCountError } from '@/errors/max-stalled-count.error'; +import { HIGHEST_SHUTDOWN_PRIORITY } from '@/constants'; +import { OnShutdown } from '@/decorators/OnShutdown'; +import { JOB_TYPE_NAME, QUEUE_NAME } from './constants'; +import { JobProcessor } from './job-processor'; +import type { JobQueue, Job, JobData, JobOptions, JobMessage, JobStatus, JobId } from './types'; +import type { IExecuteResponsePromiseData } from 'n8n-workflow'; + +@Service() +export class ScalingService { + private queue: JobQueue; + + private readonly instanceType = config.getEnv('generic.instanceType'); + + constructor( + private readonly logger: Logger, + private readonly activeExecutions: ActiveExecutions, + private readonly jobProcessor: JobProcessor, + ) {} + + // #region Lifecycle + + async setupQueue() { + const { default: BullQueue } = await import('bull'); + const { RedisClientService } = await import('@/services/redis/redis-client.service'); + const service = Container.get(RedisClientService); + + const bullPrefix = config.getEnv('queue.bull.prefix'); + const prefix = service.toValidPrefix(bullPrefix); + + this.queue = new BullQueue(QUEUE_NAME, { + prefix, + settings: config.get('queue.bull.settings'), + createClient: (type) => service.createClient({ type: `${type}(bull)` }), + }); + + this.registerListeners(); + + this.logger.debug('[ScalingService] Queue setup completed'); + } + + setupWorker(concurrency: number) { + this.assertWorker(); + + void this.queue.process( + JOB_TYPE_NAME, + concurrency, + async (job: Job) => await this.jobProcessor.processJob(job), + ); + + this.logger.debug('[ScalingService] Worker setup completed'); + } + + @OnShutdown(HIGHEST_SHUTDOWN_PRIORITY) + async pauseQueue() { + await this.queue.pause(true, true); + + this.logger.debug('[ScalingService] Queue paused'); + } + + async pingQueue() { + await this.queue.client.ping(); + } + + // #endregion + + // #region Jobs + + async addJob(jobData: JobData, jobOptions: JobOptions) { + const { executionId } = jobData; + + const job = await this.queue.add(JOB_TYPE_NAME, jobData, jobOptions); + + this.logger.info(`[ScalingService] Added job ${job.id} (execution ${executionId})`); + + return job; + } + + async getJob(jobId: JobId) { + return await this.queue.getJob(jobId); + } + + async findJobsByStatus(statuses: JobStatus[]) { + return await this.queue.getJobs(statuses); + } + + async stopJob(job: Job) { + const props = { jobId: job.id, executionId: job.data.executionId }; + + try { + if (await job.isActive()) { + await job.progress({ kind: 'abort-job' }); + this.logger.debug('[ScalingService] Stopped active job', props); + return true; + } + + await job.remove(); + this.logger.debug('[ScalingService] Stopped inactive job', props); + return true; + } catch (error: unknown) { + await job.progress({ kind: 'abort-job' }); + this.logger.error('[ScalingService] Failed to stop job', { ...props, error }); + return false; + } + } + + // #endregion + + // #region Listeners + + private registerListeners() { + this.queue.on('global:progress', (_jobId: JobId, msg: JobMessage) => { + if (msg.kind === 'respond-to-webhook') { + const { executionId, response } = msg; + this.activeExecutions.resolveResponsePromise( + executionId, + this.decodeWebhookResponse(response), + ); + } + }); + + this.queue.on('global:progress', (jobId: JobId, msg: JobMessage) => { + if (msg.kind === 'abort-job') { + this.jobProcessor.stopJob(jobId); + } + }); + + let latestAttemptTs = 0; + let cumulativeTimeoutMs = 0; + + const MAX_TIMEOUT_MS = config.getEnv('queue.bull.redis.timeoutThreshold'); + const RESET_LENGTH_MS = 30_000; + + this.queue.on('error', (error: Error) => { + this.logger.error('[ScalingService] Queue errored', { error }); + + /** + * On Redis connection failure, try to reconnect. On every failed attempt, + * increment a cumulative timeout - if this exceeds a limit, exit the + * process. Reset the cumulative timeout if >30s between retries. + */ + if (error.message.includes('ECONNREFUSED')) { + const nowTs = Date.now(); + if (nowTs - latestAttemptTs > RESET_LENGTH_MS) { + latestAttemptTs = nowTs; + cumulativeTimeoutMs = 0; + } else { + cumulativeTimeoutMs += nowTs - latestAttemptTs; + latestAttemptTs = nowTs; + if (cumulativeTimeoutMs > MAX_TIMEOUT_MS) { + this.logger.error('[ScalingService] Redis unavailable after max timeout'); + this.logger.error('[ScalingService] Exiting process...'); + process.exit(1); + } + } + + this.logger.warn('[ScalingService] Redis unavailable - retrying to connect...'); + return; + } + + if ( + this.instanceType === 'worker' && + error.message.includes('job stalled more than maxStalledCount') + ) { + throw new MaxStalledCountError(error); + } + + /** + * Non-recoverable error on worker start with Redis unavailable. + * Even if Redis recovers, worker will remain unable to process jobs. + */ + if ( + this.instanceType === 'worker' && + error.message.includes('Error initializing Lua scripts') + ) { + this.logger.error('[ScalingService] Fatal error initializing worker', { error }); + this.logger.error('[ScalingService] Exiting process...'); + process.exit(1); + } + + throw error; + }); + } + + // #endregion + + private decodeWebhookResponse( + response: IExecuteResponsePromiseData, + ): IExecuteResponsePromiseData { + if ( + typeof response === 'object' && + typeof response.body === 'object' && + response.body !== null && + '__@N8nEncodedBuffer@__' in response.body && + typeof response.body['__@N8nEncodedBuffer@__'] === 'string' + ) { + response.body = Buffer.from(response.body['__@N8nEncodedBuffer@__'], BINARY_ENCODING); + } + + return response; + } + + private assertWorker() { + if (this.instanceType === 'worker') return; + + throw new ApplicationError('This method must be called on a `worker` instance'); + } +} diff --git a/packages/cli/src/scaling/types.ts b/packages/cli/src/scaling/types.ts new file mode 100644 index 0000000000000..55d49b8c48487 --- /dev/null +++ b/packages/cli/src/scaling/types.ts @@ -0,0 +1,55 @@ +import type { + ExecutionError, + ExecutionStatus, + IExecuteResponsePromiseData, + IRun, + WorkflowExecuteMode as WorkflowExecutionMode, +} from 'n8n-workflow'; +import type Bull from 'bull'; +import type PCancelable from 'p-cancelable'; + +export type JobQueue = Bull.Queue; + +export type Job = Bull.Job; + +export type JobId = Job['id']; + +export type JobData = { + executionId: string; + loadStaticData: boolean; +}; + +export type JobResult = { + success: boolean; + error?: ExecutionError; +}; + +export type JobStatus = Bull.JobStatus; + +export type JobOptions = Bull.JobOptions; + +/** Message sent by worker to queue or by queue to worker. */ +export type JobMessage = RepondToWebhookMessage | AbortJobMessage; + +export type RepondToWebhookMessage = { + kind: 'respond-to-webhook'; + executionId: string; + response: IExecuteResponsePromiseData; +}; + +export type AbortJobMessage = { + kind: 'abort-job'; +}; + +export type RunningJob = { + executionId: string; + workflowId: string; + workflowName: string; + mode: WorkflowExecutionMode; + startedAt: Date; + retryOf: string; + status: ExecutionStatus; + run: PCancelable; +}; + +export type RunningJobSummary = Omit; diff --git a/packages/cli/src/services/orchestration/worker/types.ts b/packages/cli/src/services/orchestration/worker/types.ts index 351c56394a31c..84c515466e2c2 100644 --- a/packages/cli/src/services/orchestration/worker/types.ts +++ b/packages/cli/src/services/orchestration/worker/types.ts @@ -1,11 +1,12 @@ import type { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow'; import type { RedisServicePubSubPublisher } from '../../redis/RedisServicePubSubPublisher'; +import type { RunningJobSummary } from '@/scaling/types'; export interface WorkerCommandReceivedHandlerOptions { queueModeId: string; redisPublisher: RedisServicePubSubPublisher; - getRunningJobIds: () => string[]; - getRunningJobsSummary: () => WorkerJobStatusSummary[]; + getRunningJobIds: () => Array; + getRunningJobsSummary: () => RunningJobSummary[]; } export interface WorkerJobStatusSummary { diff --git a/packages/cli/src/webhooks/WebhookHelpers.ts b/packages/cli/src/webhooks/WebhookHelpers.ts index 8a98c74047985..f090a7ea7e318 100644 --- a/packages/cli/src/webhooks/WebhookHelpers.ts +++ b/packages/cli/src/webhooks/WebhookHelpers.ts @@ -20,7 +20,6 @@ import type { IDataObject, IDeferredPromise, IExecuteData, - IExecuteResponsePromiseData, IHttpRequestMethods, IN8nHttpFullResponse, INode, @@ -190,18 +189,6 @@ export function getWorkflowWebhooks( return returnData; } -export function encodeWebhookResponse( - response: IExecuteResponsePromiseData, -): IExecuteResponsePromiseData { - if (typeof response === 'object' && Buffer.isBuffer(response.body)) { - response.body = { - '__@N8nEncodedBuffer@__': response.body.toString(BINARY_ENCODING), - }; - } - - return response; -} - const normalizeFormData = (values: Record) => { for (const key in values) { const value = values[key]; diff --git a/packages/cli/test/integration/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts index 54c15d381de95..205a60307c643 100644 --- a/packages/cli/test/integration/commands/worker.cmd.test.ts +++ b/packages/cli/test/integration/commands/worker.cmd.test.ts @@ -1,5 +1,4 @@ import { BinaryDataService } from 'n8n-core'; -import { mock } from 'jest-mock-extended'; import { Worker } from '@/commands/worker'; import config from '@/config'; @@ -11,7 +10,7 @@ import { OrchestrationHandlerWorkerService } from '@/services/orchestration/work import { OrchestrationWorkerService } from '@/services/orchestration/worker/orchestration.worker.service'; import { License } from '@/License'; import { ExternalHooks } from '@/ExternalHooks'; -import { type JobQueue, Queue } from '@/Queue'; +import { ScalingService } from '@/scaling/scaling.service'; import { setupTestCommand } from '@test-integration/utils/testCommand'; import { mockInstance } from '../../shared/mocking'; @@ -28,12 +27,10 @@ const license = mockInstance(License); const messageEventBus = mockInstance(MessageEventBus); const logStreamingEventRelay = mockInstance(LogStreamingEventRelay); const orchestrationHandlerWorkerService = mockInstance(OrchestrationHandlerWorkerService); -const queue = mockInstance(Queue); +const scalingService = mockInstance(ScalingService); const orchestrationWorkerService = mockInstance(OrchestrationWorkerService); const command = setupTestCommand(Worker); -queue.getBullObjectInstance.mockReturnValue(mock({ on: jest.fn() })); - test('worker initializes all its components', async () => { const worker = await command.run(); @@ -45,9 +42,9 @@ test('worker initializes all its components', async () => { expect(externalHooks.init).toHaveBeenCalledTimes(1); expect(externalSecretsManager.init).toHaveBeenCalledTimes(1); expect(messageEventBus.initialize).toHaveBeenCalledTimes(1); + expect(scalingService.setupQueue).toHaveBeenCalledTimes(1); + expect(scalingService.setupWorker).toHaveBeenCalledTimes(1); expect(logStreamingEventRelay.init).toHaveBeenCalledTimes(1); - expect(queue.init).toHaveBeenCalledTimes(1); - expect(queue.process).toHaveBeenCalledTimes(1); expect(orchestrationWorkerService.init).toHaveBeenCalledTimes(1); expect(orchestrationHandlerWorkerService.initWithOptions).toHaveBeenCalledTimes(1); expect(messageEventBus.send).toHaveBeenCalledTimes(1);