diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index 8f9fbde4d3..5deb04913b 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -478,11 +478,9 @@ describe('Node Creator', () => { nodeCreatorFeature.getters.searchBar().find('input').clear().type('wa'); nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Wait'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Merge'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('wait'); nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Wait'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Merge'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('spreadsheet'); nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Spreadsheet File'); diff --git a/cypress/e2e/8-http-request-node.cy.ts b/cypress/e2e/8-http-request-node.cy.ts index c7f44e3494..1567815ddf 100644 --- a/cypress/e2e/8-http-request-node.cy.ts +++ b/cypress/e2e/8-http-request-node.cy.ts @@ -1,6 +1,8 @@ import { WorkflowPage, NDV } from '../pages'; +import { NodeCreator } from '../pages/features/node-creator'; const workflowPage = new WorkflowPage(); +const nodeCreatorFeature = new NodeCreator(); const ndv = new NDV(); describe('HTTP Request node', () => { @@ -18,4 +20,40 @@ describe('HTTP Request node', () => { ndv.getters.outputPanel().contains('fact'); }); + + describe('Credential-only HTTP Request Node variants', () => { + it('should render a modified HTTP Request Node', () => { + workflowPage.actions.addInitialNodeToCanvas('Manual'); + + workflowPage.getters.nodeCreatorPlusButton().click(); + workflowPage.getters.nodeCreatorSearchBar().type('VirusTotal'); + + expect(nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'VirusTotal')); + expect( + nodeCreatorFeature.getters + .nodeItemDescription() + .first() + .should('have.text', 'HTTP request'), + ); + + nodeCreatorFeature.actions.selectNode('VirusTotal'); + expect(ndv.getters.nodeNameContainer().should('contain.text', 'VirusTotal HTTP Request')); + expect( + ndv.getters + .parameterInput('url') + .find('input') + .should('contain.value', 'https://www.virustotal.com/api/v3/'), + ); + + // These parameters exist for normal HTTP Request Node, but are hidden for credential-only variants + expect(ndv.getters.parameterInput('authentication').should('not.exist')); + expect(ndv.getters.parameterInput('nodeCredentialType').should('not.exist')); + + expect( + workflowPage.getters + .nodeCredentialsLabel() + .should('contain.text', 'Credential for VirusTotal'), + ); + }); + }); }); diff --git a/cypress/pages/features/node-creator.ts b/cypress/pages/features/node-creator.ts index 6686de25ff..3e6a819443 100644 --- a/cypress/pages/features/node-creator.ts +++ b/cypress/pages/features/node-creator.ts @@ -20,6 +20,7 @@ export class NodeCreator extends BasePage { communityNodeTooltip: () => cy.getByTestId('node-item-community-tooltip'), noResults: () => cy.getByTestId('node-creator-no-results'), nodeItemName: () => cy.getByTestId('node-creator-item-name'), + nodeItemDescription: () => cy.getByTestId('node-creator-item-description'), activeSubcategory: () => cy.getByTestId('nodes-list-header'), expandedCategories: () => this.getters.creatorItem().find('>div').filter('.active').invoke('text'), diff --git a/packages/cli/src/CredentialTypes.ts b/packages/cli/src/CredentialTypes.ts index 5e4277f1d0..354a7649fd 100644 --- a/packages/cli/src/CredentialTypes.ts +++ b/packages/cli/src/CredentialTypes.ts @@ -17,8 +17,8 @@ export class CredentialTypes implements ICredentialTypes { return this.getCredential(credentialType).type; } - getNodeTypesToTestWith(type: string): string[] { - return this.loadNodesAndCredentials.knownCredentials[type]?.nodesToTestWith ?? []; + getSupportedNodes(type: string): string[] { + return this.loadNodesAndCredentials.knownCredentials[type]?.supportedNodes ?? []; } /** diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index bd1a175dd6..9144c9fb2d 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -490,8 +490,8 @@ export class CredentialsHelper extends ICredentialsHelper { }; } - const nodeTypesToTestWith = this.credentialTypes.getNodeTypesToTestWith(credentialType); - for (const nodeName of nodeTypesToTestWith) { + const supportedNodes = this.credentialTypes.getSupportedNodes(credentialType); + for (const nodeName of supportedNodes) { const node = this.nodeTypes.getByName(nodeName); // Always set to an array even if node is not versioned to not having diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index fa731f66bd..dabe850dde 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -290,15 +290,15 @@ export class LoadNodesAndCredentials { const { className, sourcePath, - nodesToTestWith, + supportedNodes, extends: extendsArr, } = known.credentials[type]; this.known.credentials[type] = { className, sourcePath: path.join(directory, sourcePath), - nodesToTestWith: + supportedNodes: loader instanceof PackageDirectoryLoader - ? nodesToTestWith?.map((nodeName) => `${loader.packageName}.${nodeName}`) + ? supportedNodes?.map((nodeName) => `${loader.packageName}.${nodeName}`) : undefined, extends: extendsArr, }; diff --git a/packages/core/bin/generate-known b/packages/core/bin/generate-known index df38a7ecc6..3784f15fb1 100755 --- a/packages/core/bin/generate-known +++ b/packages/core/bin/generate-known @@ -2,7 +2,8 @@ const path = require('path'); const glob = require('fast-glob'); -const { LoggerProxy } = require('n8n-workflow'); +const uniq = require('lodash/uniq'); +const { LoggerProxy, getCredentialsForNode } = require('n8n-workflow'); const { packageDir, writeJSON } = require('./common'); const { loadClassInIsolation } = require('../dist/ClassLoader'); @@ -19,48 +20,80 @@ const loadClass = (sourcePath) => { } }; -const nodesToTestWith = {}; - -const generate = async (kind) => { - const data = glob - .sync(`dist/${kind}/**/*.${kind === 'nodes' ? 'node' : kind}.js`, { - cwd: packageDir, - }) +const generateKnownNodes = async () => { + const nodeClasses = glob + .sync('dist/nodes/**/*.node.js', { cwd: packageDir }) .map(loadClass) - .filter((data) => !!data) - .reduce((obj, { className, sourcePath, instance }) => { - const name = kind === 'nodes' ? instance.description.name : instance.name; - if (!/[vV]\d.node\.js$/.test(sourcePath)) { - if (name in obj) console.error('already loaded', kind, name, sourcePath); - else obj[name] = { className, sourcePath }; + // Ignore node versions + .filter((nodeClass) => nodeClass && !/[vV]\d.node\.js$/.test(nodeClass.sourcePath)); + + const nodes = {}; + const nodesByCredential = {}; + + for (const { className, sourcePath, instance } of nodeClasses) { + const nodeName = instance.description.name; + nodes[nodeName] = { className, sourcePath }; + + for (const credential of getCredentialsForNode(instance)) { + if (!nodesByCredential[credential.name]) { + nodesByCredential[credential.name] = []; } - if (kind === 'credentials' && Array.isArray(instance.extends)) { - obj[name].extends = instance.extends; + nodesByCredential[credential.name].push(nodeName); + } + } + + LoggerProxy.info(`Detected ${Object.keys(nodes).length} nodes`); + await writeJSON('known/nodes.json', nodes); + return { nodes, nodesByCredential }; +}; + +const generateKnownCredentials = async (nodesByCredential) => { + const credentialClasses = glob + .sync(`dist/credentials/**/*.credentials.js`, { cwd: packageDir }) + .map(loadClass) + .filter((data) => !!data); + + for (const { instance } of credentialClasses) { + if (Array.isArray(instance.extends)) { + for (const extendedCredential of instance.extends) { + nodesByCredential[extendedCredential] = [ + ...(nodesByCredential[extendedCredential] ?? []), + ...(nodesByCredential[instance.name] ?? []), + ]; + } + } + } + + const credentials = credentialClasses.reduce( + (credentials, { className, sourcePath, instance }) => { + const credentialName = instance.name; + const credential = { + className, + sourcePath, + }; + + if (Array.isArray(instance.extends)) { + credential.extends = instance.extends; } - if (kind === 'nodes') { - const { credentials } = instance.description; - if (credentials && credentials.length) { - for (const credential of credentials) { - nodesToTestWith[credential.name] = nodesToTestWith[credential.name] || []; - nodesToTestWith[credential.name].push(name); - } - } - } else { - if (name in nodesToTestWith) { - obj[name].nodesToTestWith = nodesToTestWith[name]; - } + if (nodesByCredential[credentialName]) { + credential.supportedNodes = Array.from(new Set(nodesByCredential[credentialName])); } - return obj; - }, {}); - LoggerProxy.info(`Detected ${Object.keys(data).length} ${kind}`); - await writeJSON(`known/${kind}.json`, data); - return data; + credentials[credentialName] = credential; + + return credentials; + }, + {}, + ); + + LoggerProxy.info(`Detected ${Object.keys(credentials).length} credentials`); + await writeJSON('known/credentials.json', credentials); + return credentials; }; (async () => { - await generate('nodes'); - await generate('credentials'); + const { nodesByCredential } = await generateKnownNodes(); + await generateKnownCredentials(nodesByCredential); })(); diff --git a/packages/core/bin/generate-ui-types b/packages/core/bin/generate-ui-types index 317c0e121e..681cd809dd 100755 --- a/packages/core/bin/generate-ui-types +++ b/packages/core/bin/generate-ui-types @@ -45,7 +45,14 @@ function addWebhookLifecycle(nodeType) { const loader = new PackageDirectoryLoader(packageDir); await loader.loadAll(); - const credentialTypes = Object.values(loader.credentialTypes).map((data) => data.type); + const knownCredentials = loader.known.credentials; + const credentialTypes = Object.values(loader.credentialTypes).map((data) => { + const credentialType = data.type; + if (knownCredentials[credentialType.name].supportedNodes?.length > 0) { + delete credentialType.httpRequestNode; + } + return credentialType; + }); const loaderNodeTypes = Object.values(loader.nodeTypes); @@ -75,13 +82,12 @@ function addWebhookLifecycle(nodeType) { addWebhookLifecycle(nodeType); return data.type; }) - .flatMap((nodeData) => { - return NodeHelpers.getVersionedNodeTypeAll(nodeData).map((item) => { + .flatMap((nodeType) => + NodeHelpers.getVersionedNodeTypeAll(nodeType).map((item) => { const { __loadOptionsMethods, ...rest } = item.description; - return rest; - }); - }); + }), + ); const referencedMethods = findReferencedMethods(nodeTypes); diff --git a/packages/core/src/DirectoryLoader.ts b/packages/core/src/DirectoryLoader.ts index 6a5c195d8c..eb473166c5 100644 --- a/packages/core/src/DirectoryLoader.ts +++ b/packages/core/src/DirectoryLoader.ts @@ -1,7 +1,5 @@ -import * as path from 'path'; -import { readFile } from 'fs/promises'; import glob from 'fast-glob'; -import { jsonParse, getVersionedNodeTypeAll, LoggerProxy as Logger } from 'n8n-workflow'; +import { readFile } from 'fs/promises'; import type { CodexData, DocumentationLink, @@ -9,15 +7,22 @@ import type { ICredentialTypeData, INodeType, INodeTypeBaseDescription, - INodeTypeDescription, INodeTypeData, + INodeTypeDescription, INodeTypeNameVersion, IVersionedNodeType, KnownNodesAndCredentials, } from 'n8n-workflow'; +import { + LoggerProxy as Logger, + getCredentialsForNode, + getVersionedNodeTypeAll, + jsonParse, +} from 'n8n-workflow'; +import * as path from 'path'; +import { loadClassInIsolation } from './ClassLoader'; import { CUSTOM_NODES_CATEGORY } from './Constants'; import type { n8n } from './Interfaces'; -import { loadClassInIsolation } from './ClassLoader'; function toJSON(this: ICredentialType) { return { @@ -44,6 +49,8 @@ export abstract class DirectoryLoader { types: Types = { nodes: [], credentials: [] }; + protected nodesByCredential: Record = {}; + constructor( readonly directory: string, protected readonly excludeNodes: string[] = [], @@ -140,6 +147,13 @@ export abstract class DirectoryLoader { getVersionedNodeTypeAll(tempNode).forEach(({ description }) => { this.types.nodes.push(description); }); + + for (const credential of getCredentialsForNode(tempNode)) { + if (!this.nodesByCredential[credential.name]) { + this.nodesByCredential[credential.name] = []; + } + this.nodesByCredential[credential.name].push(fullNodeName); + } } protected loadCredentialFromFile(credentialName: string, filePath: string): void { @@ -168,6 +182,7 @@ export abstract class DirectoryLoader { className: credentialName, sourcePath: filePath, extends: tempCredential.extends, + supportedNodes: this.nodesByCredential[tempCredential.name], }; this.credentialTypes[tempCredential.name] = { @@ -276,19 +291,24 @@ export class CustomDirectoryLoader extends DirectoryLoader { packageName = 'CUSTOM'; override async loadAll() { - const filePaths = await glob('**/*.@(node|credentials).js', { + const nodes = await glob('**/*.node.js', { cwd: this.directory, absolute: true, }); - for (const filePath of filePaths) { - const [fileName, type] = path.parse(filePath).name.split('.'); + for (const nodePath of nodes) { + const [fileName] = path.parse(nodePath).name.split('.'); + this.loadNodeFromFile(fileName, nodePath); + } - if (type === 'node') { - this.loadNodeFromFile(fileName, filePath); - } else if (type === 'credentials') { - this.loadCredentialFromFile(fileName, filePath); - } + const credentials = await glob('**/*.credentials.js', { + cwd: this.directory, + absolute: true, + }); + + for (const credentialPath of credentials) { + const [fileName] = path.parse(credentialPath).name.split('.'); + this.loadCredentialFromFile(fileName, credentialPath); } } } @@ -315,15 +335,6 @@ export class PackageDirectoryLoader extends DirectoryLoader { const { nodes, credentials } = n8n; - if (Array.isArray(credentials)) { - for (const credential of credentials) { - const filePath = this.resolvePath(credential); - const [credentialName] = path.parse(credential).name.split('.'); - - this.loadCredentialFromFile(credentialName, filePath); - } - } - if (Array.isArray(nodes)) { for (const node of nodes) { const filePath = this.resolvePath(node); @@ -333,6 +344,15 @@ export class PackageDirectoryLoader extends DirectoryLoader { } } + if (Array.isArray(credentials)) { + for (const credential of credentials) { + const filePath = this.resolvePath(credential); + const [credentialName] = path.parse(credential).name.split('.'); + + this.loadCredentialFromFile(credentialName, filePath); + } + } + Logger.debug(`Loaded all credentials and nodes from ${this.packageName}`, { credentials: credentials?.length ?? 0, nodes: nodes?.length ?? 0, diff --git a/packages/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue b/packages/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue index cb7e4a0dfe..d186a23bc6 100644 --- a/packages/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue +++ b/packages/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue @@ -53,7 +53,12 @@ const i18n = useI18n(); -

+