feat(core): Add require-node-api-error ESLint rule for community nodes (no-changelog) (#28454)

This commit is contained in:
Garrit Franke 2026-04-21 11:12:51 +02:00 committed by GitHub
parent cb1244c041
commit fc5424477d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 386 additions and 27 deletions

View file

@ -41,30 +41,32 @@ export default [
✅ Set in the `recommended` configuration.\
☑️ Set in the `recommendedWithoutN8nCloudSupport` configuration.\
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).\
❌ Deprecated.
| Name                                | Description | 💼 | ⚠️ | 🔧 | 💡 |
| :--------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | :--- | :--- | :- | :- |
| [ai-node-package-json](docs/rules/ai-node-package-json.md) | Enforce consistency between n8n.aiNodeSdkVersion and ai-node-sdk peer dependency in community node packages | ✅ ☑️ | | | |
| [cred-class-field-icon-missing](docs/rules/cred-class-field-icon-missing.md) | Credential class must have an `icon` property defined | ✅ ☑️ | | | 💡 |
| [credential-documentation-url](docs/rules/credential-documentation-url.md) | Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug) | ✅ ☑️ | | 🔧 | |
| [credential-password-field](docs/rules/credential-password-field.md) | Ensure credential fields with sensitive names have typeOptions.password = true | ✅ ☑️ | | 🔧 | |
| [credential-test-required](docs/rules/credential-test-required.md) | Ensure credentials have a credential test | ✅ ☑️ | | | 💡 |
| [icon-validation](docs/rules/icon-validation.md) | Validate node and credential icon files exist, are SVG format, and light/dark icons are different | ✅ ☑️ | | | 💡 |
| [missing-paired-item](docs/rules/missing-paired-item.md) | Require pairedItem on INodeExecutionData objects in execute() methods to preserve item linking. | ✅ ☑️ | | | |
| [no-credential-reuse](docs/rules/no-credential-reuse.md) | Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package | ✅ ☑️ | | | 💡 |
| [no-deprecated-workflow-functions](docs/rules/no-deprecated-workflow-functions.md) | Disallow usage of deprecated functions and types from n8n-workflow package | ✅ ☑️ | | | 💡 |
| [no-forbidden-lifecycle-scripts](docs/rules/no-forbidden-lifecycle-scripts.md) | Ban lifecycle scripts (prepare, preinstall, postinstall, etc.) in community node packages | ✅ ☑️ | | | |
| [no-http-request-with-manual-auth](docs/rules/no-http-request-with-manual-auth.md) | Disallow this.helpers.httpRequest() in functions that call this.getCredentials(). Use this.helpers.httpRequestWithAuthentication() instead. | ✅ ☑️ | | | |
| [no-restricted-globals](docs/rules/no-restricted-globals.md) | Disallow usage of restricted global variables in community nodes. | ✅ | | | |
| [no-restricted-imports](docs/rules/no-restricted-imports.md) | Disallow usage of restricted imports in community nodes. | ✅ | | | |
| [node-class-description-icon-missing](docs/rules/node-class-description-icon-missing.md) | **Deprecated.** Node class description must have an `icon` property defined. Use `require-node-description-fields` instead. | ✅ ☑️ | | | 💡 |
| [node-connection-type-literal](docs/rules/node-connection-type-literal.md) | Disallow string literals in node description `inputs`/`outputs` — use `NodeConnectionTypes` enum instead | ✅ ☑️ | | 🔧 | |
| [node-usable-as-tool](docs/rules/node-usable-as-tool.md) | Ensure node classes have usableAsTool property | ✅ ☑️ | | 🔧 | |
| [options-sorted-alphabetically](docs/rules/options-sorted-alphabetically.md) | Enforce alphabetical ordering of options arrays in n8n node properties | | ✅ ☑️ | | |
| [package-name-convention](docs/rules/package-name-convention.md) | Enforce correct package naming convention for n8n community nodes | ✅ ☑️ | | | 💡 |
| [require-continue-on-fail](docs/rules/require-continue-on-fail.md) | Require continueOnFail() handling in execute() methods of node classes | ✅ ☑️ | | | |
| [require-node-description-fields](docs/rules/require-node-description-fields.md) | Node class description must define all required fields | ✅ ☑️ | | | |
| [resource-operation-pattern](docs/rules/resource-operation-pattern.md) | Enforce proper resource/operation pattern for better UX in n8n nodes | | ✅ ☑️ | | |
| Name                                | Description | 💼 | ⚠️ | 🔧 | 💡 | ❌ |
| :--------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | :--- | :--- | :- | :- | :- |
| [ai-node-package-json](docs/rules/ai-node-package-json.md) | Enforce consistency between n8n.aiNodeSdkVersion and ai-node-sdk peer dependency in community node packages | ✅ ☑️ | | | | |
| [cred-class-field-icon-missing](docs/rules/cred-class-field-icon-missing.md) | Credential class must have an `icon` property defined | ✅ ☑️ | | | 💡 | |
| [credential-documentation-url](docs/rules/credential-documentation-url.md) | Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug) | ✅ ☑️ | | 🔧 | | |
| [credential-password-field](docs/rules/credential-password-field.md) | Ensure credential fields with sensitive names have typeOptions.password = true | ✅ ☑️ | | 🔧 | | |
| [credential-test-required](docs/rules/credential-test-required.md) | Ensure credentials have a credential test | ✅ ☑️ | | | 💡 | |
| [icon-validation](docs/rules/icon-validation.md) | Validate node and credential icon files exist, are SVG format, and light/dark icons are different | ✅ ☑️ | | | 💡 | |
| [missing-paired-item](docs/rules/missing-paired-item.md) | Require pairedItem on INodeExecutionData objects in execute() methods to preserve item linking. | ✅ ☑️ | | | | |
| [no-credential-reuse](docs/rules/no-credential-reuse.md) | Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package | ✅ ☑️ | | | 💡 | |
| [no-deprecated-workflow-functions](docs/rules/no-deprecated-workflow-functions.md) | Disallow usage of deprecated functions and types from n8n-workflow package | ✅ ☑️ | | | 💡 | |
| [no-forbidden-lifecycle-scripts](docs/rules/no-forbidden-lifecycle-scripts.md) | Ban lifecycle scripts (prepare, preinstall, postinstall, etc.) in community node packages | ✅ ☑️ | | | | |
| [no-http-request-with-manual-auth](docs/rules/no-http-request-with-manual-auth.md) | Disallow this.helpers.httpRequest() in functions that call this.getCredentials(). Use this.helpers.httpRequestWithAuthentication() instead. | ✅ ☑️ | | | | |
| [no-restricted-globals](docs/rules/no-restricted-globals.md) | Disallow usage of restricted global variables in community nodes. | ✅ | | | | |
| [no-restricted-imports](docs/rules/no-restricted-imports.md) | Disallow usage of restricted imports in community nodes. | ✅ | | | | |
| [node-class-description-icon-missing](docs/rules/node-class-description-icon-missing.md) | Node class description must have an `icon` property defined. Deprecated: use `require-node-description-fields` instead. | | | | 💡 | ❌ |
| [node-connection-type-literal](docs/rules/node-connection-type-literal.md) | Disallow string literals in node description `inputs`/`outputs` — use `NodeConnectionTypes` enum instead | ✅ ☑️ | | 🔧 | | |
| [node-usable-as-tool](docs/rules/node-usable-as-tool.md) | Ensure node classes have usableAsTool property | ✅ ☑️ | | 🔧 | | |
| [options-sorted-alphabetically](docs/rules/options-sorted-alphabetically.md) | Enforce alphabetical ordering of options arrays in n8n node properties | | ✅ ☑️ | | | |
| [package-name-convention](docs/rules/package-name-convention.md) | Enforce correct package naming convention for n8n community nodes | ✅ ☑️ | | | 💡 | |
| [require-continue-on-fail](docs/rules/require-continue-on-fail.md) | Require continueOnFail() handling in execute() methods of node classes | ✅ ☑️ | | | | |
| [require-node-api-error](docs/rules/require-node-api-error.md) | Require NodeApiError or NodeOperationError for error wrapping in catch blocks. Raw errors lose HTTP context in the n8n UI. | ✅ ☑️ | | | | |
| [require-node-description-fields](docs/rules/require-node-description-fields.md) | Node class description must define all required fields: icon, subtitle | ✅ ☑️ | | | | |
| [resource-operation-pattern](docs/rules/resource-operation-pattern.md) | Enforce proper resource/operation pattern for better UX in n8n nodes | | ✅ ☑️ | | | |
<!-- end auto-generated rules list -->

View file

@ -1,11 +1,13 @@
# Node class description must have an `icon` property defined (`@n8n/community-nodes/node-class-description-icon-missing`)
# Node class description must have an `icon` property defined. Deprecated: use `require-node-description-fields` instead (`@n8n/community-nodes/node-class-description-icon-missing`)
❌ This rule is **deprecated**. Use [`require-node-description-fields`](require-node-description-fields.md) instead.
❌ This rule is deprecated.
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
<!-- end auto-generated rule header -->
> **Deprecated:** Use [`require-node-description-fields`](require-node-description-fields.md) instead.
## Rule Details
Validates that node classes define an `icon` property in their `description` object. Icons are required for nodes to display correctly in the n8n editor.

View file

@ -0,0 +1,62 @@
# Require NodeApiError or NodeOperationError for error wrapping in catch blocks. Raw errors lose HTTP context in the n8n UI (`@n8n/community-nodes/require-node-api-error`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
<!-- end auto-generated rule header -->
## Rule Details
When errors are caught and re-thrown in n8n nodes, they must be wrapped in
`NodeApiError` or `NodeOperationError`. Raw re-throws and generic `Error`
constructors lose HTTP context (status code, response body, etc.) that the n8n
UI relies on to display meaningful error information to users.
## Examples
### Incorrect
```js
try {
await apiRequest();
} catch (error) {
throw error;
}
```
```js
try {
await apiRequest();
} catch (error) {
throw new Error('Request failed');
}
```
### Correct
```js
try {
await apiRequest();
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
```
```js
try {
await apiRequest();
} catch (error) {
throw new NodeOperationError(this.getNode(), 'Operation failed', { itemIndex: i });
}
```
```js
try {
await apiRequest();
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message } });
continue;
}
throw new NodeApiError(this.getNode(), error as JsonObject);
}
```

View file

@ -1,4 +1,4 @@
# Node class description must define all required fields (`@n8n/community-nodes/require-node-description-fields`)
# Node class description must define all required fields: icon, subtitle (`@n8n/community-nodes/require-node-description-fields`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.

View file

@ -40,6 +40,7 @@ const configs = {
'@n8n/community-nodes/missing-paired-item': 'error',
'@n8n/community-nodes/require-community-node-keyword': 'warn',
'@n8n/community-nodes/require-continue-on-fail': 'error',
'@n8n/community-nodes/require-node-api-error': 'error',
'@n8n/community-nodes/require-node-description-fields': 'error',
},
},
@ -67,6 +68,7 @@ const configs = {
'@n8n/community-nodes/missing-paired-item': 'error',
'@n8n/community-nodes/require-community-node-keyword': 'warn',
'@n8n/community-nodes/require-continue-on-fail': 'error',
'@n8n/community-nodes/require-node-api-error': 'error',
'@n8n/community-nodes/require-node-description-fields': 'error',
},
},

View file

@ -20,6 +20,7 @@ import { OptionsSortedAlphabeticallyRule } from './options-sorted-alphabetically
import { PackageNameConventionRule } from './package-name-convention.js';
import { RequireCommunityNodeKeywordRule } from './require-community-node-keyword.js';
import { RequireContinueOnFailRule } from './require-continue-on-fail.js';
import { RequireNodeApiErrorRule } from './require-node-api-error.js';
import { RequireNodeDescriptionFieldsRule } from './require-node-description-fields.js';
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
@ -45,5 +46,6 @@ export const rules = {
'missing-paired-item': MissingPairedItemRule,
'require-community-node-keyword': RequireCommunityNodeKeywordRule,
'require-continue-on-fail': RequireContinueOnFailRule,
'require-node-api-error': RequireNodeApiErrorRule,
'require-node-description-fields': RequireNodeDescriptionFieldsRule,
} satisfies Record<string, AnyRuleModule>;

View file

@ -0,0 +1,199 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { RequireNodeApiErrorRule } from './require-node-api-error.js';
const ruleTester = new RuleTester();
ruleTester.run('require-node-api-error', RequireNodeApiErrorRule, {
valid: [
{
name: 'throw NodeApiError in catch block',
code: `
try {
await apiRequest();
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}`,
},
{
name: 'throw NodeOperationError in catch block',
code: `
try {
await apiRequest();
} catch (error) {
throw new NodeOperationError(this.getNode(), 'Operation failed', { itemIndex: i });
}`,
},
{
name: 'throw outside catch block (not in scope)',
code: `
function validate(input: string) {
if (!input) {
throw new Error('Input required');
}
}`,
},
{
name: 'throw new Error outside catch block (not in scope)',
code: `
throw new Error('Something went wrong');`,
},
{
name: 'continueOnFail pattern with NodeApiError',
code: `
try {
responseData = await apiRequest.call(this, 'POST', '/tasks', body);
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message } });
continue;
}
throw new NodeApiError(this.getNode(), error as JsonObject);
}`,
},
{
name: 'conditional handling then NodeApiError in else',
code: `
try {
await ftp.put(data, path);
} catch (error) {
if (error.code === 553) {
await ftp.mkdir(dirPath, true);
await ftp.put(data, path);
} else {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}`,
},
{
name: 'throw wrapped error stored in variable',
code: `
try {
await apiRequest();
} catch (error) {
const wrapped = new NodeApiError(this.getNode(), error as JsonObject);
throw wrapped;
}`,
},
{
name: 'shadowed variable with same name as catch param',
code: `
try {
await apiRequest();
} catch (error) {
const fn = (error: Error) => {
throw error;
};
}`,
},
{
name: 'no throw in catch block',
code: `
try {
await apiRequest();
} catch (error) {
console.error(error);
}`,
},
{
name: 'bare re-throw in credential file (skipped)',
filename: '/path/to/MyCredential.credentials.ts',
code: `
try {
await apiRequest();
} catch (error) {
throw error;
}`,
},
{
name: 'bare re-throw in .js file (skipped)',
filename: '/path/to/helper.js',
code: `
try {
apiRequest();
} catch (error) {
throw error;
}`,
},
],
invalid: [
{
name: 'bare re-throw of caught error',
code: `
try {
await apiRequest();
} catch (error) {
throw error;
}`,
errors: [{ messageId: 'useNodeApiError' }],
},
{
name: 'throw new Error in catch block',
code: `
try {
await apiRequest();
} catch (error) {
throw new Error('Request failed');
}`,
errors: [
{
messageId: 'useNodeApiErrorInsteadOfGeneric',
data: { errorClass: 'Error' },
},
],
},
{
name: 'bare re-throw after continueOnFail',
code: `
try {
responseData = await apiRequest.call(this, 'POST', '/tasks', body);
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message } });
continue;
}
throw error;
}`,
errors: [{ messageId: 'useNodeApiError' }],
},
{
name: 'throw new TypeError in catch block',
code: `
try {
JSON.parse(data);
} catch (error) {
throw new TypeError('Invalid JSON');
}`,
errors: [
{
messageId: 'useNodeApiErrorInsteadOfGeneric',
data: { errorClass: 'TypeError' },
},
],
},
{
name: 'bare re-throw in nested catch',
code: `
try {
try {
await apiRequest();
} catch (innerError) {
throw innerError;
}
} catch (outerError) {
throw new NodeApiError(this.getNode(), outerError as JsonObject);
}`,
errors: [{ messageId: 'useNodeApiError' }],
},
{
name: 'throw named variable in catch',
code: `
try {
await apiRequest();
} catch (e) {
throw e;
}`,
errors: [{ messageId: 'useNodeApiError' }],
},
],
});

View file

@ -0,0 +1,90 @@
import { DefinitionType } from '@typescript-eslint/scope-manager';
import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
import { isFileType } from '../utils/index.js';
import { createRule } from '../utils/rule-creator.js';
const ALLOWED_ERROR_CLASSES = new Set(['NodeApiError', 'NodeOperationError']);
function getThrowCalleeName(argument: TSESTree.Expression): string | null {
if (argument.type === AST_NODE_TYPES.NewExpression) {
if (argument.callee.type === AST_NODE_TYPES.Identifier) {
return argument.callee.name;
}
}
return null;
}
function isInsideCatchClause(node: TSESTree.Node): boolean {
let current: TSESTree.Node | undefined = node.parent;
while (current) {
if (current.type === AST_NODE_TYPES.CatchClause) {
return true;
}
current = current.parent;
}
return false;
}
export const RequireNodeApiErrorRule = createRule({
name: 'require-node-api-error',
meta: {
type: 'problem',
docs: {
description:
'Require NodeApiError or NodeOperationError for error wrapping in catch blocks. ' +
'Raw errors lose HTTP context in the n8n UI.',
},
messages: {
useNodeApiError:
'Use `NodeApiError` or `NodeOperationError` instead of re-throwing raw errors. ' +
'Example: `throw new NodeApiError(this.getNode(), error as JsonObject)`',
useNodeApiErrorInsteadOfGeneric:
'Use `NodeApiError` or `NodeOperationError` instead of `{{ errorClass }}`. ' +
'Example: `throw new NodeApiError(this.getNode(), error as JsonObject)`',
},
schema: [],
},
defaultOptions: [],
create(context) {
const isNodeFile = isFileType(context.filename, '.node.ts');
const isHelperFile =
context.filename.endsWith('.ts') &&
!isNodeFile &&
!isFileType(context.filename, '.credentials.ts');
if (!isNodeFile && !isHelperFile) {
return {};
}
return {
ThrowStatement(node) {
if (!isInsideCatchClause(node)) return;
if (!node.argument) return;
const { argument } = node;
if (argument.type === AST_NODE_TYPES.Identifier) {
const scope = context.sourceCode.getScope(node);
const ref = scope.references.find((r) => r.identifier === argument);
const isCatchParam =
ref?.resolved?.defs.some((def) => def.type === DefinitionType.CatchClause) ?? false;
if (isCatchParam) {
context.report({ node, messageId: 'useNodeApiError' });
}
return;
}
const calleeName = getThrowCalleeName(argument);
if (calleeName !== null && !ALLOWED_ERROR_CLASSES.has(calleeName)) {
context.report({
node,
messageId: 'useNodeApiErrorInsteadOfGeneric',
data: { errorClass: calleeName },
});
}
},
};
},
});