diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 5d86a914a0d08..a7efac11586a2 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -791,7 +791,7 @@ function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequest // if there is a body and it's empty (does not have properties), // make sure not to send anything in it as some services fail when // sending GET request with empty body. - if (typeof body === 'object' && !isObjectEmpty(body)) { + if (typeof body === 'string' || (typeof body === 'object' && !isObjectEmpty(body))) { axiosRequest.data = body; } } diff --git a/packages/nodes-base/credentials/NpmApi.credentials.ts b/packages/nodes-base/credentials/NpmApi.credentials.ts new file mode 100644 index 0000000000000..8b6c825b69c7e --- /dev/null +++ b/packages/nodes-base/credentials/NpmApi.credentials.ts @@ -0,0 +1,46 @@ +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class NpmApi implements ICredentialType { + name = 'npmApi'; + + displayName = 'Npm API'; + + documentationUrl = 'npm'; + + properties: INodeProperties[] = [ + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string', + typeOptions: { password: true }, + default: '', + }, + { + displayName: 'Registry Url', + name: 'registryUrl', + type: 'string', + default: 'https://registry.npmjs.org', + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '=Bearer {{$credentials.accessToken}}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: '={{$credentials.registryUrl}}', + url: '/-/whoami', + }, + }; +} diff --git a/packages/nodes-base/nodes/Npm/DistTagDescription.ts b/packages/nodes-base/nodes/Npm/DistTagDescription.ts new file mode 100644 index 0000000000000..759c559e1bbfe --- /dev/null +++ b/packages/nodes-base/nodes/Npm/DistTagDescription.ts @@ -0,0 +1,94 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const distTagOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'getMany', + displayOptions: { + show: { + resource: ['distTag'], + }, + }, + + options: [ + { + name: 'Get All', + value: 'getMany', + action: 'Returns all the dist-tags for a package', + description: 'Returns all the dist-tags for a package', + routing: { + request: { + method: 'GET', + url: '=/-/package/{{ encodeURIComponent($parameter.packageName) }}/dist-tags', + }, + }, + }, + { + name: 'Update', + value: 'update', + action: 'Update a the dist-tags for a package', + description: 'Update a the dist-tags for a package', + routing: { + request: { + method: 'PUT', + url: '=/-/package/{{ encodeURIComponent($parameter.packageName) }}/dist-tags/{{ encodeURIComponent($parameter.distTagName) }}', + }, + send: { + preSend: [ + async function (this, requestOptions) { + requestOptions.headers!['content-type'] = 'application/x-www-form-urlencoded'; + requestOptions.body = this.getNodeParameter('packageVersion'); + return requestOptions; + }, + ], + }, + }, + }, + ], + }, +]; + +export const distTagFields: INodeProperties[] = [ + { + displayName: 'Package Name', + name: 'packageName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['distTag'], + operation: ['getMany', 'update'], + }, + }, + }, + { + displayName: 'Package Version', + name: 'packageVersion', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['distTag'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Distribution Tag Name', + name: 'distTagName', + type: 'string', + required: true, + default: 'latest', + displayOptions: { + show: { + resource: ['distTag'], + operation: ['update'], + }, + }, + }, +]; diff --git a/packages/nodes-base/nodes/Npm/Npm.node.json b/packages/nodes-base/nodes/Npm/Npm.node.json new file mode 100644 index 0000000000000..39394b5ed4ccc --- /dev/null +++ b/packages/nodes-base/nodes/Npm/Npm.node.json @@ -0,0 +1,19 @@ +{ + "node": "n8n-nodes-base.npm", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Development"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/npm" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.npm/" + } + ], + "generic": [] + } +} diff --git a/packages/nodes-base/nodes/Npm/Npm.node.ts b/packages/nodes-base/nodes/Npm/Npm.node.ts new file mode 100644 index 0000000000000..54d8a44fbd4b7 --- /dev/null +++ b/packages/nodes-base/nodes/Npm/Npm.node.ts @@ -0,0 +1,54 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { packageFields, packageOperations } from './PackageDescription'; +import { distTagFields, distTagOperations } from './DistTagDescription'; + +export class Npm implements INodeType { + description: INodeTypeDescription = { + displayName: 'Npm', + name: 'npm', + icon: 'file:npm.svg', + group: ['input'], + version: 1, + subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}', + description: 'Consume NPM registry API', + defaults: { + name: 'npm', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'npmApi', + required: false, + }, + ], + requestDefaults: { + baseURL: '={{ $credentials.registryUrl }}', + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Package', + value: 'package', + }, + { + name: 'Distribution Tag', + value: 'distTag', + }, + ], + default: 'package', + }, + + ...packageOperations, + ...packageFields, + + ...distTagOperations, + ...distTagFields, + ], + }; +} diff --git a/packages/nodes-base/nodes/Npm/PackageDescription.ts b/packages/nodes-base/nodes/Npm/PackageDescription.ts new file mode 100644 index 0000000000000..4f88f96cfd6ab --- /dev/null +++ b/packages/nodes-base/nodes/Npm/PackageDescription.ts @@ -0,0 +1,181 @@ +import { valid as isValidSemver } from 'semver'; +import type { INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +interface PackageJson { + name: string; + version: string; + description: string; +} + +export const packageOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'getMetadata', + displayOptions: { + show: { + resource: ['package'], + }, + }, + options: [ + { + name: 'Get Metadata', + value: 'getMetadata', + action: 'Returns all the metadata for a package at a specific version', + description: 'Returns all the metadata for a package at a specific version', + routing: { + request: { + method: 'GET', + url: '=/{{ encodeURIComponent($parameter.packageName) }}/{{ $parameter.packageVersion }}', + }, + }, + }, + { + name: 'Get Versions', + value: 'getVersions', + action: 'Returns all the versions for a package', + description: 'Returns all the versions for a package', + routing: { + request: { + method: 'GET', + url: '=/{{ encodeURIComponent($parameter.packageName) }}', + }, + output: { + postReceive: [ + async function (items) { + const allVersions: INodeExecutionData[] = []; + for (const { json } of items) { + const itemVersions = json.time as Record; + Object.keys(itemVersions).forEach((version) => { + if (isValidSemver(version)) { + allVersions.push({ + json: { + version, + published_at: itemVersions[version], + }, + }); + } + }); + } + allVersions.sort( + (a, b) => + new Date(b.json.published_at as string).getTime() - + new Date(a.json.published_at as string).getTime(), + ); + return allVersions; + }, + ], + }, + }, + }, + { + name: 'Search', + value: 'search', + action: 'Search for packages', + description: 'Search for packages', + routing: { + request: { + method: 'GET', + url: '/-/v1/search', + qs: { + text: '={{$parameter.query}}', + size: '={{$parameter.limit}}', + from: '={{$parameter.offset}}', + popularity: 0.99, + }, + }, + output: { + postReceive: [ + async function (items) { + return items.flatMap(({ json }) => + (json.objects as Array<{ package: PackageJson }>).map( + ({ package: { name, version, description } }) => + ({ json: { name, version, description } } as INodeExecutionData), + ), + ); + }, + ], + }, + }, + }, + ], + }, +]; + +export const packageFields: INodeProperties[] = [ + { + displayName: 'Package Name', + name: 'packageName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['package'], + operation: ['getMetadata', 'getVersions'], + }, + }, + }, + { + displayName: 'Package Version', + name: 'packageVersion', + type: 'string', + required: true, + default: 'latest', + displayOptions: { + show: { + resource: ['package'], + operation: ['getMetadata'], + }, + }, + }, + { + displayName: 'Query', + name: 'query', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['package'], + operation: ['search'], + }, + }, + default: '', + description: 'The query text used to search for packages', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 10, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + displayOptions: { + show: { + resource: ['package'], + operation: ['search'], + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'Offset', + name: 'offset', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + }, + displayOptions: { + show: { + resource: ['package'], + operation: ['search'], + }, + }, + description: 'Offset to return results from', + }, +]; diff --git a/packages/nodes-base/nodes/Npm/npm.svg b/packages/nodes-base/nodes/Npm/npm.svg new file mode 100644 index 0000000000000..1c1bfc69bff71 --- /dev/null +++ b/packages/nodes-base/nodes/Npm/npm.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/nodes-base/nodes/Npm/test/Npm.node.test.ts b/packages/nodes-base/nodes/Npm/test/Npm.node.test.ts new file mode 100644 index 0000000000000..95d621240be06 --- /dev/null +++ b/packages/nodes-base/nodes/Npm/test/Npm.node.test.ts @@ -0,0 +1,38 @@ +import nock from 'nock'; +import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers'; +import { FAKE_CREDENTIALS_DATA } from '@test/nodes/FakeCredentialsMap'; + +describe('Test npm Node', () => { + beforeAll(() => { + nock.disableNetConnect(); + + const { registryUrl } = FAKE_CREDENTIALS_DATA.npmApi; + const mock = nock(registryUrl); //.matchHeader('Authorization', `Bearer ${accessToken}`); + + mock.get('/-/package/n8n/dist-tags').reply(200, { + latest: '0.225.2', + next: '0.226.2', + }); + + mock.get('/n8n').reply(200, { + time: { + '0.225.2': '2023-04-25T09:45:36.407Z', + '0.226.2': '2023-05-03T09:41:30.844Z', + '0.227.0': '2023-05-03T13:44:32.079Z', + }, + }); + + mock.get('/n8n/latest').reply(200, { + name: 'n8n', + version: '0.225.2', + rest: 'of the properties', + }); + }); + + afterAll(() => { + nock.restore(); + }); + + const workflows = getWorkflowFilenames(__dirname); + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Npm/test/Npm.workflow.test.json b/packages/nodes-base/nodes/Npm/test/Npm.workflow.test.json new file mode 100644 index 0000000000000..794101292eaec --- /dev/null +++ b/packages/nodes-base/nodes/Npm/test/Npm.workflow.test.json @@ -0,0 +1,117 @@ +{ + "name": "Test NPM", + "nodes": [ + { + "parameters": {}, + "name": "Execute Workflow", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [620, 440] + }, + { + "parameters": { + "resource": "distTag", + "operation": "getMany", + "packageName": "n8n" + }, + "name": "Get All dist-tags", + "type": "n8n-nodes-base.npm", + "credentials": { + "npmApi": { + "id": "1" + } + } + }, + { + "parameters": { + "resource": "package", + "operation": "getVersions", + "packageName": "n8n" + }, + "name": "Get Versions", + "type": "n8n-nodes-base.npm", + "credentials": { + "npmApi": { + "id": "1" + } + } + }, + { + "parameters": { + "resource": "package", + "operation": "getMetadata", + "packageName": "n8n", + "packageVersion": "latest" + }, + "name": "Get Metadata", + "type": "n8n-nodes-base.npm", + "credentials": { + "npmApi": { + "id": "1" + } + } + } + ], + "pinData": { + "Get All dist-tags": [ + { + "json": { + "latest": "0.225.2", + "next": "0.226.2" + } + } + ], + "Get Versions": [ + { + "json": { + "version": "0.227.0", + "published_at": "2023-05-03T13:44:32.079Z" + } + }, + { + "json": { + "version": "0.226.2", + "published_at": "2023-05-03T09:41:30.844Z" + } + }, + { + "json": { + "version": "0.225.2", + "published_at": "2023-04-25T09:45:36.407Z" + } + } + ], + "Get Metadata": [ + { + "json": { + "name": "n8n", + "version": "0.225.2", + "rest": "of the properties" + } + } + ] + }, + "connections": { + "Execute Workflow": { + "main": [ + [ + { + "node": "Get All dist-tags", + "type": "main", + "index": 0 + }, + { + "node": "Get Versions", + "type": "main", + "index": 0 + }, + { + "node": "Get Metadata", + "type": "main", + "index": 0 + } + ] + ] + } + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index ab802acfc8153..1ee983bb38272 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -223,6 +223,7 @@ "dist/credentials/NocoDbApiToken.credentials.js", "dist/credentials/NotionApi.credentials.js", "dist/credentials/NotionOAuth2Api.credentials.js", + "dist/credentials/NpmApi.credentials.js", "dist/credentials/OAuth1Api.credentials.js", "dist/credentials/OAuth2Api.credentials.js", "dist/credentials/OdooApi.credentials.js", @@ -596,6 +597,7 @@ "dist/nodes/Onfleet/OnfleetTrigger.node.js", "dist/nodes/Notion/Notion.node.js", "dist/nodes/Notion/NotionTrigger.node.js", + "dist/nodes/Npm/Npm.node.js", "dist/nodes/Odoo/Odoo.node.js", "dist/nodes/OneSimpleApi/OneSimpleApi.node.js", "dist/nodes/OpenAi/OpenAi.node.js", @@ -896,6 +898,7 @@ "request": "^2.88.2", "rhea": "^1.0.11", "rss-parser": "^3.7.0", + "semver": "^7.3.8", "showdown": "^2.0.3", "simple-git": "^3.17.0", "snowflake-sdk": "^1.5.3", diff --git a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts index 875083385d719..83c82b3dbbe2d 100644 --- a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts +++ b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts @@ -1,10 +1,8 @@ -import type { IDataObject } from 'n8n-workflow'; - // If your test needs data from credentials, you can add it here. // as JSON.stringify({ id: 'credentials_ID', name: 'credentials_name' }) for specific credentials // or as 'credentials_type' for all credentials of that type // expected keys for credentials can be found in packages/nodes-base/credentials/[credentials_type].credentials.ts -export const FAKE_CREDENTIALS_DATA: IDataObject = { +export const FAKE_CREDENTIALS_DATA = { [JSON.stringify({ id: '20', name: 'Airtable account' })]: { apiKey: 'key456', }, @@ -15,6 +13,10 @@ export const FAKE_CREDENTIALS_DATA: IDataObject = { apiKey: 'key123', baseUrl: 'https://test.app.n8n.cloud/api/v1', }, + npmApi: { + accessToken: 'fake-npm-access-token', + registryUrl: 'https://fake.npm.registry', + }, totpApi: { label: 'GitHub:john-doe', secret: 'BVDRSBXQB2ZEL5HE', @@ -24,4 +26,4 @@ export const FAKE_CREDENTIALS_DATA: IDataObject = { accessKeyId: 'key', secretAccessKey: 'secret', }, -}; +} as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64008f00c5a86..76a7095196788 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1417,6 +1417,9 @@ importers: rss-parser: specifier: ^3.7.0 version: 3.12.0 + semver: + specifier: ^7.3.8 + version: 7.3.8 showdown: specifier: ^2.0.3 version: 2.1.0