feat: implement node parser and property extractor with versioned node support

This commit is contained in:
czlonkowski
2025-06-12 18:45:20 +02:00
parent f831b02415
commit 66f5d74e42
27 changed files with 1390 additions and 164 deletions

164
src/parsers/node-parser.ts Normal file
View File

@@ -0,0 +1,164 @@
import { PropertyExtractor } from './property-extractor';
export interface ParsedNode {
style: 'declarative' | 'programmatic';
nodeType: string;
displayName: string;
description?: string;
category?: string;
properties: any[];
credentials: any[];
isAITool: boolean;
isTrigger: boolean;
isWebhook: boolean;
operations: any[];
version?: string;
isVersioned: boolean;
packageName: string;
documentation?: string;
}
export class NodeParser {
private propertyExtractor = new PropertyExtractor();
parse(nodeClass: any, packageName: string): ParsedNode {
// Get base description (handles versioned nodes)
const description = this.getNodeDescription(nodeClass);
return {
style: this.detectStyle(nodeClass),
nodeType: this.extractNodeType(description, packageName),
displayName: description.displayName || description.name,
description: description.description,
category: this.extractCategory(description),
properties: this.propertyExtractor.extractProperties(nodeClass),
credentials: this.propertyExtractor.extractCredentials(nodeClass),
isAITool: this.propertyExtractor.detectAIToolCapability(nodeClass),
isTrigger: this.detectTrigger(description),
isWebhook: this.detectWebhook(description),
operations: this.propertyExtractor.extractOperations(nodeClass),
version: this.extractVersion(nodeClass),
isVersioned: this.detectVersioned(nodeClass),
packageName: packageName
};
}
private getNodeDescription(nodeClass: any): any {
// Try to get description from the class first
let description: any;
// Check if it's a versioned node (has baseDescription and nodeVersions)
if (typeof nodeClass === 'function' && nodeClass.prototype &&
nodeClass.prototype.constructor &&
nodeClass.prototype.constructor.name === 'VersionedNodeType') {
// This is a VersionedNodeType class - instantiate it
const instance = new nodeClass();
description = instance.baseDescription || {};
} else if (typeof nodeClass === 'function') {
// Try to instantiate to get description
try {
const instance = new nodeClass();
description = instance.description || {};
// For versioned nodes, we might need to look deeper
if (!description.name && instance.baseDescription) {
description = instance.baseDescription;
}
} catch (e) {
// Some nodes might require parameters to instantiate
// Try to access static properties
description = nodeClass.description || {};
}
} else {
// Maybe it's already an instance
description = nodeClass.description || {};
}
return description;
}
private detectStyle(nodeClass: any): 'declarative' | 'programmatic' {
const desc = this.getNodeDescription(nodeClass);
return desc.routing ? 'declarative' : 'programmatic';
}
private extractNodeType(description: any, packageName: string): string {
// Ensure we have the full node type including package prefix
const name = description.name;
if (!name) {
throw new Error('Node is missing name property');
}
if (name.includes('.')) {
return name;
}
// Add package prefix if missing
const packagePrefix = packageName.replace('@n8n/', '').replace('n8n-', '');
return `${packagePrefix}.${name}`;
}
private extractCategory(description: any): string {
return description.group?.[0] ||
description.categories?.[0] ||
description.category ||
'misc';
}
private detectTrigger(description: any): boolean {
return description.polling === true ||
description.trigger === true ||
description.eventTrigger === true ||
description.name?.toLowerCase().includes('trigger');
}
private detectWebhook(description: any): boolean {
return (description.webhooks?.length > 0) ||
description.webhook === true ||
description.name?.toLowerCase().includes('webhook');
}
private extractVersion(nodeClass: any): string {
if (nodeClass.baseDescription?.defaultVersion) {
return nodeClass.baseDescription.defaultVersion.toString();
}
if (nodeClass.nodeVersions) {
const versions = Object.keys(nodeClass.nodeVersions);
return Math.max(...versions.map(Number)).toString();
}
// Check instance for nodeVersions
try {
const instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
if (instance?.nodeVersions) {
const versions = Object.keys(instance.nodeVersions);
return Math.max(...versions.map(Number)).toString();
}
} catch (e) {
// Ignore
}
return nodeClass.description?.version || '1';
}
private detectVersioned(nodeClass: any): boolean {
// Check class-level nodeVersions
if (nodeClass.nodeVersions || nodeClass.baseDescription?.defaultVersion) {
return true;
}
// Check instance-level nodeVersions
try {
const instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
if (instance?.nodeVersions) {
return true;
}
} catch (e) {
// Ignore
}
return false;
}
}

View File

@@ -0,0 +1,215 @@
export class PropertyExtractor {
/**
* Extract properties with proper handling of n8n's complex structures
*/
extractProperties(nodeClass: any): any[] {
const properties: any[] = [];
// First try to get instance-level properties
let instance: any;
try {
instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
} catch (e) {
// Failed to instantiate
}
// Handle versioned nodes - check instance for nodeVersions
if (instance?.nodeVersions) {
const versions = Object.keys(instance.nodeVersions);
const latestVersion = Math.max(...versions.map(Number));
const versionedNode = instance.nodeVersions[latestVersion];
if (versionedNode?.description?.properties) {
return this.normalizeProperties(versionedNode.description.properties);
}
}
// Check for description with properties
const description = instance?.description || instance?.baseDescription ||
this.getNodeDescription(nodeClass);
if (description?.properties) {
return this.normalizeProperties(description.properties);
}
return properties;
}
private getNodeDescription(nodeClass: any): any {
// Try to get description from the class first
let description: any;
if (typeof nodeClass === 'function') {
// Try to instantiate to get description
try {
const instance = new nodeClass();
description = instance.description || instance.baseDescription || {};
} catch (e) {
// Some nodes might require parameters to instantiate
description = nodeClass.description || {};
}
} else {
description = nodeClass.description || {};
}
return description;
}
/**
* Extract operations from both declarative and programmatic nodes
*/
extractOperations(nodeClass: any): any[] {
const operations: any[] = [];
// First try to get instance-level data
let instance: any;
try {
instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
} catch (e) {
// Failed to instantiate
}
// Handle versioned nodes
if (instance?.nodeVersions) {
const versions = Object.keys(instance.nodeVersions);
const latestVersion = Math.max(...versions.map(Number));
const versionedNode = instance.nodeVersions[latestVersion];
if (versionedNode?.description) {
return this.extractOperationsFromDescription(versionedNode.description);
}
}
// Get description
const description = instance?.description || instance?.baseDescription ||
this.getNodeDescription(nodeClass);
return this.extractOperationsFromDescription(description);
}
private extractOperationsFromDescription(description: any): any[] {
const operations: any[] = [];
if (!description) return operations;
// Declarative nodes (with routing)
if (description.routing) {
const routing = description.routing;
// Extract from request.resource and request.operation
if (routing.request?.resource) {
const resources = routing.request.resource.options || [];
const operationOptions = routing.request.operation?.options || {};
resources.forEach((resource: any) => {
const resourceOps = operationOptions[resource.value] || [];
resourceOps.forEach((op: any) => {
operations.push({
resource: resource.value,
operation: op.value,
name: `${resource.name} - ${op.name}`,
action: op.action
});
});
});
}
}
// Programmatic nodes - look for operation property in properties
if (description.properties && Array.isArray(description.properties)) {
const operationProp = description.properties.find(
(p: any) => p.name === 'operation' || p.name === 'action'
);
if (operationProp?.options) {
operationProp.options.forEach((op: any) => {
operations.push({
operation: op.value,
name: op.name,
description: op.description
});
});
}
}
return operations;
}
/**
* Deep search for AI tool capability
*/
detectAIToolCapability(nodeClass: any): boolean {
const description = this.getNodeDescription(nodeClass);
// Direct property check
if (description?.usableAsTool === true) return true;
// Check in actions for declarative nodes
if (description?.actions?.some((a: any) => a.usableAsTool === true)) return true;
// Check versioned nodes
if (nodeClass.nodeVersions) {
for (const version of Object.values(nodeClass.nodeVersions)) {
if ((version as any).description?.usableAsTool === true) return true;
}
}
// Check for specific AI-related properties
const aiIndicators = ['openai', 'anthropic', 'huggingface', 'cohere', 'ai'];
const nodeName = description?.name?.toLowerCase() || '';
return aiIndicators.some(indicator => nodeName.includes(indicator));
}
/**
* Extract credential requirements with proper structure
*/
extractCredentials(nodeClass: any): any[] {
const credentials: any[] = [];
// First try to get instance-level data
let instance: any;
try {
instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
} catch (e) {
// Failed to instantiate
}
// Handle versioned nodes
if (instance?.nodeVersions) {
const versions = Object.keys(instance.nodeVersions);
const latestVersion = Math.max(...versions.map(Number));
const versionedNode = instance.nodeVersions[latestVersion];
if (versionedNode?.description?.credentials) {
return versionedNode.description.credentials;
}
}
// Check for description with credentials
const description = instance?.description || instance?.baseDescription ||
this.getNodeDescription(nodeClass);
if (description?.credentials) {
return description.credentials;
}
return credentials;
}
private normalizeProperties(properties: any[]): any[] {
// Ensure all properties have consistent structure
return properties.map(prop => ({
displayName: prop.displayName,
name: prop.name,
type: prop.type,
default: prop.default,
description: prop.description,
options: prop.options,
required: prop.required,
displayOptions: prop.displayOptions,
typeOptions: prop.typeOptions,
noDataExpression: prop.noDataExpression
}));
}
}