diff --git a/packages/@n8n/api-types/src/community-node-types.ts b/packages/@n8n/api-types/src/community-node-types.ts index fc543a762b..5261c4440e 100644 --- a/packages/@n8n/api-types/src/community-node-types.ts +++ b/packages/@n8n/api-types/src/community-node-types.ts @@ -1,6 +1,6 @@ import type { INodeTypeDescription } from 'n8n-workflow'; -export interface CommunityNodeAttributes { +export type CommunityNodeType = { authorGithubUrl: string; authorName: string; checksum: string; @@ -13,11 +13,8 @@ export interface CommunityNodeAttributes { createdAt: string; updatedAt: string; npmVersion: string; -} - -export interface CommunityNodeData { - id: number; - attributes: CommunityNodeAttributes & { - nodeDescription: INodeTypeDescription; - }; -} + isOfficialNode: boolean; + companyName?: string; + nodeDescription: INodeTypeDescription; + isInstalled: boolean; +}; diff --git a/packages/cli/src/controllers/__tests__/community-packages.controller.test.ts b/packages/cli/src/controllers/__tests__/community-packages.controller.test.ts index 1f1bfc3d6c..9319fab4c0 100644 --- a/packages/cli/src/controllers/__tests__/community-packages.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/community-packages.controller.test.ts @@ -1,7 +1,6 @@ -import type { CommunityNodeAttributes } from '@n8n/api-types'; +import type { CommunityNodeType } from '@n8n/api-types'; import type { InstalledPackages } from '@n8n/db'; import { mock } from 'jest-mock-extended'; -import type { INodeTypeDescription } from 'n8n-workflow'; import { CommunityPackagesController } from '@/controllers/community-packages.controller'; import type { NodeRequest } from '@/requests'; @@ -46,7 +45,7 @@ describe('CommunityPackagesController', () => { body: { name: 'n8n-nodes-test', verify: true, version: '1.0.0' }, }); communityNodeTypesService.findVetted.mockReturnValue( - mock({ + mock({ checksum: 'checksum', }), ); diff --git a/packages/cli/src/controllers/community-node-types.controller.ts b/packages/cli/src/controllers/community-node-types.controller.ts index 23c46bc742..d0af778691 100644 --- a/packages/cli/src/controllers/community-node-types.controller.ts +++ b/packages/cli/src/controllers/community-node-types.controller.ts @@ -1,4 +1,4 @@ -import type { CommunityNodeAttributes } from '@n8n/api-types'; +import type { CommunityNodeType } from '@n8n/api-types'; import { Get, RestController } from '@n8n/decorators'; import { Request } from 'express'; @@ -9,12 +9,12 @@ export class CommunityNodeTypesController { constructor(private readonly communityNodeTypesService: CommunityNodeTypesService) {} @Get('/:name') - async getCommunityNodeAttributes(req: Request): Promise { - return this.communityNodeTypesService.getCommunityNodeAttributes(req.params.name); + async getCommunityNodeType(req: Request): Promise { + return await this.communityNodeTypesService.getCommunityNodeType(req.params.name); } @Get('/') async getCommunityNodeTypes() { - return await this.communityNodeTypesService.getDescriptions(); + return await this.communityNodeTypesService.getCommunityNodeTypes(); } } diff --git a/packages/cli/src/services/community-node-types.service.ts b/packages/cli/src/services/community-node-types.service.ts index 1beb536b21..3fa47ee86f 100644 --- a/packages/cli/src/services/community-node-types.service.ts +++ b/packages/cli/src/services/community-node-types.service.ts @@ -1,25 +1,35 @@ -import type { CommunityNodeAttributes, CommunityNodeData } from '@n8n/api-types'; +import type { CommunityNodeType } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { Logger } from 'n8n-core'; -import type { INodeTypeDescription } from 'n8n-workflow'; -import { ensureError } from 'n8n-workflow'; +import { ensureError, type INodeTypeDescription } from 'n8n-workflow'; import { CommunityPackagesService } from './community-packages.service'; -import { paginatedRequest } from '../utils/community-nodes-request-utils'; +import { getCommunityNodeTypes } from '../utils/community-node-types-utils'; const UPDATE_INTERVAL = 8 * 60 * 60 * 1000; -const N8N_VETTED_NODE_TYPES_STAGING_URL = 'https://api-staging.n8n.io/api/community-nodes'; -const N8N_VETTED_NODE_TYPES_PRODUCTION_URL = 'https://api.n8n.io/api/community-nodes'; +export type StrapiCommunityNodeType = { + authorGithubUrl: string; + authorName: string; + checksum: string; + description: string; + displayName: string; + name: string; + numberOfStars: number; + numberOfDownloads: number; + packageName: string; + createdAt: string; + updatedAt: string; + npmVersion: string; + isOfficialNode: boolean; + companyName?: string; + nodeDescription: INodeTypeDescription; +}; @Service() export class CommunityNodeTypesService { - private communityNodes: { - [key: string]: CommunityNodeAttributes & { - nodeDescription: INodeTypeDescription; - }; - } = {}; + private communityNodeTypes: Map = new Map(); private lastUpdateTimestamp = 0; @@ -31,39 +41,33 @@ export class CommunityNodeTypesService { private async fetchNodeTypes() { try { - let data: CommunityNodeData[] = []; + let data: StrapiCommunityNodeType[] = []; if ( this.globalConfig.nodes.communityPackages.enabled && this.globalConfig.nodes.communityPackages.verifiedEnabled ) { const environment = this.globalConfig.license.tenantId === 1 ? 'production' : 'staging'; - const url = - environment === 'production' - ? N8N_VETTED_NODE_TYPES_PRODUCTION_URL - : N8N_VETTED_NODE_TYPES_STAGING_URL; - data = await paginatedRequest(url); + data = await getCommunityNodeTypes(environment); } - this.updateData(data); + this.updateCommunityNodeTypes(data); } catch (error) { this.logger.error('Failed to fetch community node types', { error: ensureError(error) }); } } - private updateData(data: CommunityNodeData[]) { - if (!data?.length) return; + private updateCommunityNodeTypes(nodeTypes: StrapiCommunityNodeType[]) { + if (!nodeTypes?.length) return; - this.resetData(); + this.resetCommunityNodeTypes(); - for (const entry of data) { - this.communityNodes[entry.attributes.name] = entry.attributes; - } + this.communityNodeTypes = new Map(nodeTypes.map((nodeType) => [nodeType.name, nodeType])); this.lastUpdateTimestamp = Date.now(); } - private resetData() { - this.communityNodes = {}; + private resetCommunityNodeTypes() { + this.communityNodeTypes = new Map(); } private updateRequired() { @@ -71,36 +75,37 @@ export class CommunityNodeTypesService { return Date.now() - this.lastUpdateTimestamp > UPDATE_INTERVAL; } - async getDescriptions(): Promise { - const nodesDescriptions: INodeTypeDescription[] = []; + private async createIsInstalled() { + const installedPackages = (await this.communityPackagesService.getAllInstalledPackages()) ?? []; + const installedPackageNames = new Set(installedPackages.map((p) => p.packageName)); - if (this.updateRequired() || !Object.keys(this.communityNodes).length) { + return (nodeTypeName: string) => installedPackageNames.has(nodeTypeName.split('.')[0]); + } + + async getCommunityNodeTypes(): Promise { + if (this.updateRequired() || !this.communityNodeTypes.size) { await this.fetchNodeTypes(); } - const installedPackages = ( - (await this.communityPackagesService.getAllInstalledPackages()) ?? [] - ).map((p) => p.packageName); + const isInstalled = await this.createIsInstalled(); - for (const node of Object.values(this.communityNodes)) { - if (installedPackages.includes(node.name.split('.')[0])) continue; - nodesDescriptions.push(node.nodeDescription); - } - - return nodesDescriptions; + return Array.from(this.communityNodeTypes.values()).map((nodeType) => ({ + ...nodeType, + isInstalled: isInstalled(nodeType.name), + })); } - getCommunityNodeAttributes(type: string): CommunityNodeAttributes | null { - const node = this.communityNodes[type]; - if (!node) return null; - const { nodeDescription, ...attributes } = node; - return attributes; + async getCommunityNodeType(type: string): Promise { + const nodeType = this.communityNodeTypes.get(type); + const isInstalled = await this.createIsInstalled(); + if (!nodeType) return null; + return { ...nodeType, isInstalled: isInstalled(nodeType.name) }; } findVetted(packageName: string) { - const vettedTypes = Object.keys(this.communityNodes); + const vettedTypes = Array.from(this.communityNodeTypes.keys()); const nodeName = vettedTypes.find((t) => t.includes(packageName)); if (!nodeName) return; - return this.communityNodes[nodeName]; + return this.communityNodeTypes.get(nodeName); } } diff --git a/packages/cli/src/utils/__tests__/community-nodes-request.test.ts b/packages/cli/src/utils/__tests__/community-nodes-request.test.ts deleted file mode 100644 index 723f84da5a..0000000000 --- a/packages/cli/src/utils/__tests__/community-nodes-request.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { CommunityNodeData } from '@n8n/api-types'; -import nock from 'nock'; - -import { paginatedRequest } from '../community-nodes-request-utils'; - -describe('strapiPaginatedRequest', () => { - const baseUrl = 'https://strapi.test/api/nodes'; - - afterEach(() => { - nock.cleanAll(); - }); - - it('should fetch and combine multiple pages of data', async () => { - const page1 = [ - { - id: 1, - attributes: { name: 'Node1', nodeDescription: { name: 'n1', version: 1 } } as any, - }, - ]; - - const page2 = [ - { - id: 2, - attributes: { name: 'Node2', nodeDescription: { name: 'n2', version: 2 } } as any, - }, - ]; - - nock('https://strapi.test') - .get('/api/nodes') - .query(true) - .reply(200, { - data: page1, - meta: { - pagination: { - page: 1, - pageSize: 25, - pageCount: 2, - total: 2, - }, - }, - }); - - nock('https://strapi.test') - .get('/api/nodes') - .query(true) - .reply(200, { - data: page2, - meta: { - pagination: { - page: 2, - pageSize: 25, - pageCount: 2, - total: 2, - }, - }, - }); - - const result = await paginatedRequest('https://strapi.test/api/nodes'); - - expect(result).toHaveLength(2); - expect(result[0].id).toBe(1); - expect(result[1].id).toBe(2); - }); - - it('should return empty array if no data', async () => { - nock('https://strapi.test') - .get('/api/nodes') - .query(true) - .reply(200, { - data: [], - meta: { - pagination: { - page: 1, - pageSize: 25, - pageCount: 0, - total: 0, - }, - }, - }); - - const result = await paginatedRequest(baseUrl); - expect(result).toEqual([]); - }); - - it('should return single page data', async () => { - const singlePage: CommunityNodeData[] = [ - { - id: 1, - attributes: { - name: 'NodeSingle', - nodeDescription: { name: 'n1', version: 1 }, - } as any, - }, - ]; - - nock('https://strapi.test') - .get('/api/nodes') - .query(true) - .reply(200, { - data: singlePage, - meta: { - pagination: { - page: 1, - pageSize: 25, - pageCount: 1, - total: 1, - }, - }, - }); - - const result = await paginatedRequest(baseUrl); - expect(result).toHaveLength(1); - expect(result[0].attributes.name).toBe('NodeSingle'); - }); - - it('should return an empty array if the request fails', async () => { - const endpoint = '/nodes'; - - nock(baseUrl).get(endpoint).query(true).replyWithError('Request failed'); - - const result = await paginatedRequest(`${baseUrl}${endpoint}`); - - expect(result).toEqual([]); - }); -}); diff --git a/packages/cli/src/utils/__tests__/strapi-utils.test.ts b/packages/cli/src/utils/__tests__/strapi-utils.test.ts new file mode 100644 index 0000000000..1f76575d6b --- /dev/null +++ b/packages/cli/src/utils/__tests__/strapi-utils.test.ts @@ -0,0 +1,128 @@ +import nock from 'nock'; + +import { paginatedRequest } from '../strapi-utils'; + +describe('Strapi utils', () => { + describe('paginatedRequest', () => { + const baseUrl = 'https://strapi.test/api/nodes'; + + afterEach(() => { + nock.cleanAll(); + }); + + it('should fetch and combine multiple pages of data', async () => { + const page1 = [ + { + id: 1, + attributes: { name: 'Node1', nodeDescription: { name: 'n1', version: 1 } }, + }, + ]; + + const page2 = [ + { + id: 2, + attributes: { name: 'Node2', nodeDescription: { name: 'n2', version: 2 } }, + }, + ]; + + nock('https://strapi.test') + .get('/api/nodes') + .query(true) + .reply(200, { + data: page1, + meta: { + pagination: { + page: 1, + pageSize: 25, + pageCount: 2, + total: 2, + }, + }, + }); + + nock('https://strapi.test') + .get('/api/nodes') + .query(true) + .reply(200, { + data: page2, + meta: { + pagination: { + page: 2, + pageSize: 25, + pageCount: 2, + total: 2, + }, + }, + }); + + const result = await paginatedRequest<(typeof page1)[number]['attributes']>( + 'https://strapi.test/api/nodes', + ); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('Node1'); + expect(result[1].name).toBe('Node2'); + }); + + it('should return empty array if no data', async () => { + nock('https://strapi.test') + .get('/api/nodes') + .query(true) + .reply(200, { + data: [], + meta: { + pagination: { + page: 1, + pageSize: 25, + pageCount: 0, + total: 0, + }, + }, + }); + + const result = await paginatedRequest(baseUrl); + expect(result).toEqual([]); + }); + + it('should return single page data', async () => { + const singlePage = [ + { + id: 1, + attributes: { + name: 'NodeSingle', + nodeDescription: { name: 'n1', version: 1 }, + }, + }, + ]; + + nock('https://strapi.test') + .get('/api/nodes') + .query(true) + .reply(200, { + data: singlePage, + meta: { + pagination: { + page: 1, + pageSize: 25, + pageCount: 1, + total: 1, + }, + }, + }); + + const result = await paginatedRequest<(typeof singlePage)[number]['attributes']>(baseUrl); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('NodeSingle'); + }); + + it('should return an empty array if the request fails', async () => { + const endpoint = '/nodes'; + + nock(baseUrl).get(endpoint).query(true).replyWithError('Request failed'); + + const result = await paginatedRequest(`${baseUrl}${endpoint}`); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/cli/src/utils/community-node-types-utils.ts b/packages/cli/src/utils/community-node-types-utils.ts new file mode 100644 index 0000000000..afc352a7cd --- /dev/null +++ b/packages/cli/src/utils/community-node-types-utils.ts @@ -0,0 +1,35 @@ +import type { INodeTypeDescription } from 'n8n-workflow'; + +import { paginatedRequest } from './strapi-utils'; + +export type StrapiCommunityNodeType = { + authorGithubUrl: string; + authorName: string; + checksum: string; + description: string; + displayName: string; + name: string; + numberOfStars: number; + numberOfDownloads: number; + packageName: string; + createdAt: string; + updatedAt: string; + npmVersion: string; + isOfficialNode: boolean; + companyName?: string; + nodeDescription: INodeTypeDescription; +}; + +const N8N_VETTED_NODE_TYPES_STAGING_URL = 'https://api-staging.n8n.io/api/community-nodes'; +const N8N_VETTED_NODE_TYPES_PRODUCTION_URL = 'https://api.n8n.io/api/community-nodes'; + +export async function getCommunityNodeTypes( + environment: 'staging' | 'production', +): Promise { + const url = + environment === 'production' + ? N8N_VETTED_NODE_TYPES_PRODUCTION_URL + : N8N_VETTED_NODE_TYPES_STAGING_URL; + + return await paginatedRequest(url); +} diff --git a/packages/cli/src/utils/community-nodes-request-utils.ts b/packages/cli/src/utils/strapi-utils.ts similarity index 72% rename from packages/cli/src/utils/community-nodes-request-utils.ts rename to packages/cli/src/utils/strapi-utils.ts index da63ef24c5..aaf2e2d281 100644 --- a/packages/cli/src/utils/community-nodes-request-utils.ts +++ b/packages/cli/src/utils/strapi-utils.ts @@ -1,10 +1,9 @@ -import type { CommunityNodeData } from '@n8n/api-types'; import { Container } from '@n8n/di'; import axios from 'axios'; import { ErrorReporter, Logger } from 'n8n-core'; -interface ResponseData { - data: CommunityNodeData[]; +interface ResponseData { + data: Array>; meta: Meta; } @@ -12,6 +11,11 @@ interface Meta { pagination: Pagination; } +export interface Entity { + id: number; + attributes: T; +} + interface Pagination { page: number; pageSize: number; @@ -19,9 +23,9 @@ interface Pagination { total: number; } -export async function paginatedRequest(url: string): Promise { - let returnData: CommunityNodeData[] = []; - let responseData: CommunityNodeData[] | undefined = []; +export async function paginatedRequest(url: string): Promise { + let returnData: T[] = []; + let responseData: T[] | undefined = []; const params = { pagination: { @@ -33,7 +37,7 @@ export async function paginatedRequest(url: string): Promise(url, { + response = await axios.get>(url, { headers: { 'Content-Type': 'application/json' }, params, }); @@ -47,7 +51,7 @@ export async function paginatedRequest(url: string): Promise item.attributes); if (!responseData?.length) break; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue index 1cb98a2499..0a902e7b30 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue @@ -4,7 +4,6 @@ import { ElTag } from 'element-plus'; import { useI18n } from '../../composables/useI18n'; import type { NodeCreatorTag } from '../../types/node-creator-node'; -import N8nTooltip from '../N8nTooltip'; export interface Props { active?: boolean; @@ -14,6 +13,7 @@ export interface Props { tag?: NodeCreatorTag; title: string; showActionArrow?: boolean; + isOfficial?: boolean; } defineProps(); @@ -22,6 +22,8 @@ defineEmits<{ tooltipClick: [e: MouseEvent]; }>(); +defineSlots<{ icon: {}; extraDetails: {}; dragContent: {} }>(); + const { t } = useI18n(); @@ -49,16 +51,8 @@ const { t } = useI18n(); :title="t('nodeCreator.nodeItem.triggerIconTitle')" :class="$style.triggerIcon" /> - - - - + +

diff --git a/packages/frontend/editor-ui/src/api/nodeTypes.ts b/packages/frontend/editor-ui/src/api/nodeTypes.ts index a1860e4a20..2c3b9785fb 100644 --- a/packages/frontend/editor-ui/src/api/nodeTypes.ts +++ b/packages/frontend/editor-ui/src/api/nodeTypes.ts @@ -1,12 +1,13 @@ +import type { INodeTranslationHeaders, IRestApiContext } from '@/Interface'; +import { makeRestApiRequest } from '@/utils/apiUtils'; import type { ActionResultRequestDto, + CommunityNodeType, OptionsRequestDto, ResourceLocatorRequestDto, ResourceMapperFieldsRequestDto, } from '@n8n/api-types'; -import { makeRestApiRequest } from '@/utils/apiUtils'; -import type { INodeTranslationHeaders, IRestApiContext } from '@/Interface'; -import type { CommunityNodeAttributes } from '@n8n/api-types'; +import axios from 'axios'; import { type INodeListSearchResult, type INodePropertyOptions, @@ -16,7 +17,6 @@ import { type ResourceMapperFields, sleep, } from 'n8n-workflow'; -import axios from 'axios'; async function fetchNodeTypesJsonWithRetry(url: string, retries = 5, delay = 500) { for (let attempt = 0; attempt < retries; attempt++) { @@ -38,14 +38,14 @@ export async function getNodeTypes(baseUrl: string) { export async function fetchCommunityNodeTypes( context: IRestApiContext, -): Promise { +): Promise { return await makeRestApiRequest(context, 'GET', '/community-node-types'); } export async function fetchCommunityNodeAttributes( context: IRestApiContext, type: string, -): Promise { +): Promise { return await makeRestApiRequest( context, 'GET', diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/NodeItem.vue b/packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/NodeItem.vue index 00ebc64d49..9798175f30 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/NodeItem.vue +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/NodeItem.vue @@ -1,5 +1,4 @@