fix(core): Fix populating of node custom api call options (#5347)

* feat(core): Fix populating of node custom api call options

* lint fixes

* Adress PR comments

* Add e2e test and only inject custom API options for latest version

* Make sure to injectCustomApiCallOption for the latest version of node

* feat(cli): Move apiCallOption injection to LoadNodesAndCredentials and add e2e tests to check for custom nodes credentials

* Load nodes and credentials fixtures from a single place

* Console warning if credential is invalid during customApiOptions injection
This commit is contained in:
OlegIvaniv
2023-02-03 13:14:59 +01:00
committed by GitHub
parent 4dab2fec49
commit 6985500a7d
16 changed files with 269 additions and 104 deletions

View File

@@ -12,6 +12,7 @@ import type {
ILogger,
INodesAndCredentials,
KnownNodesAndCredentials,
INodeTypeDescription,
LoadedNodesAndCredentials,
} from 'n8n-workflow';
import { LoggerProxy, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
@@ -29,7 +30,13 @@ import config from '@/config';
import type { InstalledPackages } from '@db/entities/InstalledPackages';
import type { InstalledNodes } from '@db/entities/InstalledNodes';
import { executeCommand } from '@/CommunityNodes/helpers';
import { CLI_DIR, GENERATED_STATIC_DIR, RESPONSE_ERROR_MESSAGES } from '@/constants';
import {
CLI_DIR,
GENERATED_STATIC_DIR,
RESPONSE_ERROR_MESSAGES,
CUSTOM_API_CALL_KEY,
CUSTOM_API_CALL_NAME,
} from '@/constants';
import {
persistInstalledPackageData,
removePackageFromDatabase,
@@ -66,6 +73,7 @@ export class LoadNodesAndCredentialsClass implements INodesAndCredentials {
await this.loadNodesFromBasePackages();
await this.loadNodesFromDownloadedPackages();
await this.loadNodesFromCustomDirectories();
this.injectCustomApiCallOptions();
}
async generateTypesForFrontend() {
@@ -307,6 +315,60 @@ export class LoadNodesAndCredentialsClass implements INodesAndCredentials {
}
}
/**
* Whether any of the node's credential types may be used to
* make a request from a node other than itself.
*/
private supportsProxyAuth(description: INodeTypeDescription) {
if (!description.credentials) return false;
return description.credentials.some(({ name }) => {
const credType = this.types.credentials.find((t) => t.name === name);
if (!credType) {
LoggerProxy.warn(
`Failed to load Custom API options for the node "${description.name}": Unknown credential name "${name}"`,
);
return false;
}
if (credType.authenticate !== undefined) return true;
return (
Array.isArray(credType.extends) &&
credType.extends.some((parentType) =>
['oAuth2Api', 'googleOAuth2Api', 'oAuth1Api'].includes(parentType),
)
);
});
}
/**
* Inject a `Custom API Call` option into `resource` and `operation`
* parameters in a latest-version node that supports proxy auth.
*/
private injectCustomApiCallOptions() {
this.types.nodes.forEach((node: INodeTypeDescription) => {
const isLatestVersion =
node.defaultVersion === undefined || node.defaultVersion === node.version;
if (isLatestVersion) {
if (!this.supportsProxyAuth(node)) return;
node.properties.forEach((p) => {
if (
['resource', 'operation'].includes(p.name) &&
Array.isArray(p.options) &&
p.options[p.options.length - 1].name !== CUSTOM_API_CALL_NAME
) {
p.options.push({
name: CUSTOM_API_CALL_NAME,
value: CUSTOM_API_CALL_KEY,
});
}
});
}
});
}
private unloadNodes(installedNodes: InstalledNodes[]): void {
installedNodes.forEach((installedNode) => {
delete this.loaded.nodes[installedNode.type];

View File

@@ -2,69 +2,13 @@ import express from 'express';
import { readFile } from 'fs/promises';
import get from 'lodash.get';
import type { ICredentialType, INodeTypeDescription, INodeTypeNameVersion } from 'n8n-workflow';
import type { INodeTypeDescription, INodeTypeNameVersion } from 'n8n-workflow';
import { CredentialTypes } from '@/CredentialTypes';
import config from '@/config';
import { NodeTypes } from '@/NodeTypes';
import * as ResponseHelper from '@/ResponseHelper';
import { getNodeTranslationPath } from '@/TranslationHelpers';
function isOAuth(credType: ICredentialType) {
return (
Array.isArray(credType.extends) &&
credType.extends.some((parentType) =>
['oAuth2Api', 'googleOAuth2Api', 'oAuth1Api'].includes(parentType),
)
);
}
/**
* Whether any of the node's credential types may be used to
* make a request from a node other than itself.
*/
function supportsProxyAuth(description: INodeTypeDescription) {
if (!description.credentials) return false;
const credentialTypes = CredentialTypes();
return description.credentials.some(({ name }) => {
const credType = credentialTypes.getByName(name);
if (credType.authenticate !== undefined) return true;
return isOAuth(credType);
});
}
const CUSTOM_API_CALL_NAME = 'Custom API Call';
const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
/**
* Inject a `Custom API Call` option into `resource` and `operation`
* parameters in a node that supports proxy auth.
*/
function injectCustomApiCallOption(description: INodeTypeDescription) {
if (!supportsProxyAuth(description)) return description;
description.properties.forEach((p) => {
if (
['resource', 'operation'].includes(p.name) &&
Array.isArray(p.options) &&
p.options[p.options.length - 1].name !== CUSTOM_API_CALL_NAME
) {
p.options.push({
name: CUSTOM_API_CALL_NAME,
value: CUSTOM_API_CALL_KEY,
});
}
return p;
});
return description;
}
export const nodeTypesController = express.Router();
// Returns node information based on node names and versions
@@ -78,7 +22,7 @@ nodeTypesController.post(
if (defaultLocale === 'en') {
return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => {
const { description } = NodeTypes().getByNameAndVersion(name, version);
acc.push(injectCustomApiCallOption(description));
acc.push(description);
return acc;
}, []);
}
@@ -103,7 +47,7 @@ nodeTypesController.post(
// ignore - no translation exists at path
}
nodeTypes.push(injectCustomApiCallOption(description));
nodeTypes.push(description);
}
const nodeTypes: INodeTypeDescription[] = [];

View File

@@ -12,6 +12,8 @@ export const inProduction = NODE_ENV === 'production';
export const inDevelopment = !NODE_ENV || NODE_ENV === 'development';
export const inTest = NODE_ENV === 'test';
export const inE2ETests = E2E_TESTS === 'true';
export const CUSTOM_API_CALL_NAME = 'Custom API Call';
export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
export const CLI_DIR = resolve(__dirname, '..');
export const TEMPLATES_DIR = join(CLI_DIR, 'templates');

View File

@@ -99,10 +99,10 @@ export abstract class DirectoryLoader {
}
} else {
// Short renaming to avoid type issues
const tmpNode = tempNode;
nodeVersion = Array.isArray(tmpNode.description.version)
? tmpNode.description.version.slice(-1)[0]
: tmpNode.description.version;
nodeVersion = Array.isArray(tempNode.description.version)
? tempNode.description.version.slice(-1)[0]
: tempNode.description.version;
}
this.known.nodes[fullNodeName] = {

View File

@@ -233,7 +233,7 @@ const telemetry = instance?.proxy.$telemetry;
const { categorizedItems: allNodes, isTriggerNode } = useNodeTypesStore();
const containsAPIAction = computed(
() =>
state.latestNodeData?.properties.some((p) =>
activeNodeActions.value?.properties.some((p) =>
p.options?.find((o) => o.name === CUSTOM_API_CALL_NAME),
) === true,
);
@@ -338,27 +338,10 @@ function getCustomAPICallHintLocale(key: string) {
interpolate: { nodeNameTitle },
});
}
// The nodes.json doesn't contain API CALL option so we need to fetch the node detail
// to determine if need to render the API CALL hint
async function fetchNodeDetails() {
if (!state.activeNodeActions) return;
const { getNodesInformation } = useNodeTypesStore();
const { version, name } = state.activeNodeActions;
const payload = {
name,
version: Array.isArray(version) ? version?.slice(-1)[0] : version,
} as INodeTypeNameVersion;
const nodesInfo = await getNodesInformation([payload], false);
state.latestNodeData = nodesInfo[0];
}
function setActiveActionsNodeType(nodeType: INodeTypeDescription | null) {
state.activeNodeActions = nodeType;
setShowTabs(false);
fetchNodeDetails();
if (nodeType) trackActionsView();
}

View File

@@ -116,7 +116,11 @@
</n8n-text>
</div>
<div v-if="isCustomApiCallSelected(nodeValues)" class="parameter-item parameter-notice">
<div
v-if="isCustomApiCallSelected(nodeValues)"
class="parameter-item parameter-notice"
data-test-id="node-parameters-http-notice"
>
<n8n-notice
:content="
$locale.baseText('nodeSettings.useTheHttpRequestNode', {

View File

@@ -56,9 +56,12 @@ const customNodeActionsParsers: {
},
};
function filterSinglePlaceholderAction(actions: INodeActionTypeDescription[]) {
function filterActions(actions: INodeActionTypeDescription[]) {
return actions.filter(
(action: INodeActionTypeDescription, _: number, arr: INodeActionTypeDescription[]) => {
const isApiCall = action.actionKey === CUSTOM_API_CALL_KEY;
if (isApiCall) return false;
const isPlaceholderTriggerAction = action.actionKey === PLACEHOLDER_RECOMMENDED_ACTION_KEY;
return !isPlaceholderTriggerAction || (isPlaceholderTriggerAction && arr.length > 1);
},
@@ -339,7 +342,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, {
const filteredNodes = Object.values(mergedNodes).map((node) => ({
...node,
actions: filterSinglePlaceholderAction(node.actions || []),
actions: filterActions(node.actions || []),
}));
return filteredNodes;