UNPKG

n8n-nodes-arubacentral

Version:

n8n community node for Aruba Central API integration with comprehensive monitoring, configuration, and management capabilities

586 lines (474 loc) 21 kB
# Development Guide for Aruba Central n8n Integration ## File Structure Here's our structure: ``` . ├── ArubaCentral.node.ts ├── api/ │ ├── descriptions.ts # Combines all domain descriptions │ ├── [domain]/ # Each API domain (e.g., monitoring) │ │ ├── [domain].descriptions.ts # Combines all resources' descriptions │ │ ├── [domain].methods.ts # Combines all resources' methods │ │ └── [resource]/ # Each resource in the domain │ │ ├── [resource].descriptions.ts # Combines all operation descriptions │ │ ├── [resource].methods.ts # Exports all operations │ │ ├── [resource].types.ts # Type definitions │ │ └── operations/ # Individual operations │ │ ├── [operation].descriptions.ts # UI parameters │ │ └── [operation].methods.ts # Implementation ├── credentials/ ├── helpers/ │ ├── apiRequest.ts # API request handling │ ├── deduplicate.ts # Description deduplication │ ├── errorHandler.ts # Error handling │ └── executeOperation.ts # Operation execution └── shared/ ``` ## Helper Function for Deduplication First, let's create the deduplication helper: ```typescript // helpers/deduplicate.ts import { INodeProperties } from 'n8n-workflow'; /** * Adds descriptions to a target array, avoiding duplicates * * @param target The target array to add descriptions to * @param source The source array of descriptions to add */ export function addDescriptions(target: INodeProperties[], source: INodeProperties[] | undefined): void { if (!Array.isArray(source) || source.length === 0) return; source.forEach(item => { const exists = target.some(existing => existing.name === item.name && existing.type === item.type && JSON.stringify(existing.displayOptions) === JSON.stringify(item.displayOptions) ); if (!exists) { target.push(item); } }); } ``` ## Step-by-Step Guide for Implementing New Domains ### Step 1: Parse the API Documentation Start by parsing the API documentation .json files to extract: - Available endpoints - Required parameters - Response structures Use this script to extract key information from API docs: ```javascript // scripts/parse-api-docs.js const fs = require('fs'); const path = require('path'); // Parse an API documentation file function parseApiDoc(domainName) { try { const filePath = path.join(__dirname, `../api-docs/${domainName}.json`); const apiData = JSON.parse(fs.readFileSync(filePath, 'utf8')); const endpoints = []; // Extract paths from the Swagger/OpenAPI format Object.entries(apiData.paths || {}).forEach(([path, methods]) => { Object.entries(methods).forEach(([method, details]) => { endpoints.push({ path, method: method.toUpperCase(), operationId: details.operationId || `${method}${path.replace(/\//g, '_').replace(/[{}]/g, '')}`, summary: details.summary || '', description: details.description || '', parameters: details.parameters || [], responses: details.responses || {}, }); }); }); // Group endpoints logically by resource const resources = {}; endpoints.forEach(endpoint => { // Use heuristics to determine the resource from the path const pathParts = endpoint.path.split('/').filter(Boolean); const resourceName = pathParts[1] || 'default'; // Use 2nd part of path as resource name if (!resources[resourceName]) { resources[resourceName] = []; } resources[resourceName].push(endpoint); }); return { resources, schemas: apiData.components?.schemas || {} }; } catch (error) { console.error(`Error parsing API doc for ${domainName}:`, error); return { resources: {}, schemas: {} }; } } // Example usage const domainName = process.argv[2]; if (!domainName) { console.error('Please provide a domain name'); process.exit(1); } const { resources, schemas } = parseApiDoc(domainName); console.log(`Found ${Object.keys(resources).length} resources in domain ${domainName}`); Object.entries(resources).forEach(([resource, endpoints]) => { console.log(`- ${resource}: ${endpoints.length} endpoints`); }); // Output to a structured file for further processing fs.writeFileSync( path.join(__dirname, `../api-structure/${domainName}-structure.json`), JSON.stringify({ resources, schemas }, null, 2) ); ``` ### Step 2: Generate Skeleton Files for a Domain Create a script to generate the skeleton structure for a new domain: ```javascript // scripts/generate-domain.js const fs = require('fs'); const path = require('path'); function generateDomain(domainName, resources) { // Create the domain directory const domainDir = path.join(__dirname, `../src/api/${domainName}`); if (!fs.existsSync(domainDir)) { fs.mkdirSync(domainDir, { recursive: true }); } // Generate domain descriptions file const descriptionsContent = `import { INodeProperties } from 'n8n-workflow'; import { addDescriptions } from '../../helpers/deduplicate'; ${resources.map(r => `import { ${r}Descriptions } from './${r}/${r}.descriptions';`).join('\n')} // Resource selection for the ${domainName} domain export const ${domainName}ResourceSelector: INodeProperties = { displayName: 'Resource', name: 'resource', type: 'options', noDataExpression: true, displayOptions: { show: { domain: ['${domainName}'], }, }, options: [ ${resources.map(r => ` { name: '${r.charAt(0).toUpperCase() + r.slice(1).replace(/([A-Z])/g, ' $1')}', value: '${r}', description: 'Work with ${r.replace(/([A-Z])/g, ' $1').toLowerCase()} information', },`).join('\n')} ], default: '${resources[0] || 'default'}', }; // Combined descriptions for all resources in this domain export const ${domainName}Descriptions: INodeProperties[] = [${domainName}ResourceSelector]; // Add each resource's descriptions ${resources.map(r => `addDescriptions(${domainName}Descriptions, ${r}Descriptions);`).join('\n')} `; fs.writeFileSync(path.join(domainDir, `${domainName}.descriptions.ts`), descriptionsContent); // Generate domain methods file const methodsContent = `${resources.map(r => `import { ${r}Operations } from './${r}/${r}.methods';`).join('\n')} import { logger } from '../../helpers/logger'; /** * All operations in the ${domainName} domain */ export const ${domainName}Operations = { ${resources.map(r => ` ${r}: ${r}Operations,`).join('\n')} }; /** * Check if an operation exists in the ${domainName} domain * * @param resource Resource name * @param operation Operation name * @returns boolean indicating if the operation exists */ export function hasOperation(resource: string, operation: string): boolean { if (!${domainName}Operations[resource]) { logger.debug('${domainName}:check', \`Resource "\${resource}" not found\`); return false; } if (!${domainName}Operations[resource][operation]) { logger.debug('${domainName}:check', \`Operation "\${operation}" not found in resource "\${resource}"\`); return false; } return true; } `; fs.writeFileSync(path.join(domainDir, `${domainName}.methods.ts`), methodsContent); // Generate directories and skeleton files for each resource resources.forEach(resourceName => { generateResource(domainName, resourceName); }); console.log(`Generated domain: ${domainName}`); } function generateResource(domainName, resourceName) { const resourceDir = path.join(__dirname, `../src/api/${domainName}/${resourceName}`); const operationsDir = path.join(resourceDir, 'operations'); if (!fs.existsSync(operationsDir)) { fs.mkdirSync(operationsDir, { recursive: true }); } // Generate resource descriptions file const descriptionsContent = `import { INodeProperties } from 'n8n-workflow'; import { addDescriptions } from '../../../helpers/deduplicate'; // Import operation descriptions here // Example: import { getDetailsDescription } from './operations/getDetails.descriptions'; /** * Operation selector for the ${resourceName} resource */ export const ${resourceName}OperationSelector: INodeProperties = { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['${resourceName}'], domain: ['${domainName}'], }, }, options: [ // Add operations here // Example: // { // name: 'Get Details', // value: 'getDetails', // description: 'Get details for a specific item', // action: 'Get details', // }, ], default: '', }; // Combined descriptions for all operations in this resource export const ${resourceName}Descriptions: INodeProperties[] = [${resourceName}OperationSelector]; // Add each operation's descriptions // Example: addDescriptions(${resourceName}Descriptions, getDetailsDescription); `; fs.writeFileSync(path.join(resourceDir, `${resourceName}.descriptions.ts`), descriptionsContent); // Generate resource methods file const methodsContent = `import { IExecuteFunctions } from 'n8n-workflow'; import { logger } from '../../../helpers/logger'; // Import operation methods here // Example: import { getDetails } from './operations/getDetails.methods'; /** * All operations available for the ${resourceName} resource */ export const ${resourceName}Operations = { // Add operations here // Example: getDetails, }; /** * Check if a ${resourceName} operation exists * * @param operation Operation name to check * @returns boolean indicating if the operation exists */ export function has${resourceName.charAt(0).toUpperCase() + resourceName.slice(1)}Operation(operation: string): boolean { const exists = !!${resourceName}Operations[operation as keyof typeof ${resourceName}Operations]; logger.debug('${domainName}:${resourceName}:check', \`Operation "\${operation}" \${exists ? 'exists' : 'does not exist'}\`); return exists; } `; fs.writeFileSync(path.join(resourceDir, `${resourceName}.methods.ts`), methodsContent); // Generate resource types file const typesContent = `/** * Type definitions for the ${resourceName} resource */ // Example: // export interface ${resourceName.charAt(0).toUpperCase() + resourceName.slice(1)} { // id: string; // name: string; // [key: string]: any; // } // Validation function export function validate${resourceName.charAt(0).toUpperCase() + resourceName.slice(1)}Response<T>(response: any): response is T { if (response === null || typeof response !== 'object') { return false; } return true; } `; fs.writeFileSync(path.join(resourceDir, `${resourceName}.types.ts`), typesContent); console.log(`Generated resource: ${resourceName}`); } // Example usage const domainName = process.argv[2]; const resources = process.argv.slice(3); if (!domainName || resources.length === 0) { console.error('Usage: node generate-domain.js domainName resource1 resource2 ...'); process.exit(1); } generateDomain(domainName, resources); ``` ### Step 3: Generate Operation Files Create a script to generate operation files based on API endpoints: ```javascript // scripts/generate-operations.js const fs = require('fs'); const path = require('path'); function generateOperation(domainName, resourceName, operationName, method, endpoint, params = []) { const operationsDir = path.join(__dirname, `../src/api/${domainName}/${resourceName}/operations`); if (!fs.existsSync(operationsDir)) { fs.mkdirSync(operationsDir, { recursive: true }); } // Generate operation method file const methodsContent = `import { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import { apiRequest } from '../../../../helpers/apiRequest'; import { logger } from '../../../../helpers/logger'; import { handleApiError } from '../../../../helpers/errorHandler'; /** * ${operationName.replace(/([A-Z])/g, ' $1').trim()} * * @param this The n8n execution context * @returns Formatted API response */ export async function ${operationName}(this: IExecuteFunctions): Promise<INodeExecutionData[]> { try { // Get parameters ${params.map(param => ` const ${param.name} = this.getNodeParameter('${param.name}', 0) ${param.type === 'boolean' ? 'as boolean' : param.type === 'number' ? 'as number' : 'as string'};`).join('\n')} // Construct API endpoint const endpoint = \`${endpoint.replace(/{([^}]+)}/g, '${$1}')}\`; // Make API request const response = await apiRequest.call(this, '${method}', endpoint); // Format and return response return [{ json: response }]; } catch (error) { logger.error('${domainName}:${resourceName}:${operationName}:error', { message: error.message }); return handleApiError.call(this, error, 'Failed to execute ${operationName.replace(/([A-Z])/g, ' $1').toLowerCase()}'); } } `; fs.writeFileSync(path.join(operationsDir, `${operationName}.methods.ts`), methodsContent); // Generate operation descriptions file const descriptionsContent = `import { INodeProperties } from 'n8n-workflow'; export const ${operationName}Description: INodeProperties[] = [ ${params.map(param => ` { displayName: '${param.name.charAt(0).toUpperCase() + param.name.slice(1).replace(/([A-Z])/g, ' $1')}', name: '${param.name}', type: '${param.type || 'string'}', ${param.required ? 'required: true,' : ''} default: ${param.type === 'boolean' ? 'false' : param.type === 'number' ? '0' : "''"}, displayOptions: { show: { resource: ['${resourceName}'], domain: ['${domainName}'], operation: ['${operationName}'], }, }, description: '${param.description || ''}', },`).join('\n')} ]; `; fs.writeFileSync(path.join(operationsDir, `${operationName}.descriptions.ts`), descriptionsContent); // Update resource descriptions file to include the new operation const resourceDescPath = path.join(__dirname, `../src/api/${domainName}/${resourceName}/${resourceName}.descriptions.ts`); let resourceDescContent = fs.readFileSync(resourceDescPath, 'utf8'); // Add import for the operation description if (!resourceDescContent.includes(`import { ${operationName}Description }`)) { const importPos = resourceDescContent.indexOf('// Import operation descriptions here'); const importLine = `import { ${operationName}Description } from './operations/${operationName}.descriptions';\n`; resourceDescContent = resourceDescContent.slice(0, importPos) + importLine + resourceDescContent.slice(importPos); } // Add operation to the options array const optionsPos = resourceDescContent.indexOf(' options: ['); const opLinePos = resourceDescContent.indexOf(' ],', optionsPos); const operationOption = ` { name: '${operationName.replace(/([A-Z])/g, ' $1').trim()}', value: '${operationName}', description: '${operationName.replace(/([A-Z])/g, ' $1').toLowerCase()}', action: '${operationName.replace(/([A-Z])/g, ' $1').toLowerCase()}', },\n`; resourceDescContent = resourceDescContent.slice(0, opLinePos) + operationOption + resourceDescContent.slice(opLinePos); // Add description to the addDescriptions section const addDescPos = resourceDescContent.indexOf('// Add each operation\'s descriptions'); const addDescLine = `addDescriptions(${resourceName}Descriptions, ${operationName}Description);\n`; resourceDescContent = resourceDescContent.slice(0, addDescPos) + addDescLine + resourceDescContent.slice(addDescPos); fs.writeFileSync(resourceDescPath, resourceDescContent); // Update resource methods file to include the new operation const resourceMethodsPath = path.join(__dirname, `../src/api/${domainName}/${resourceName}/${resourceName}.methods.ts`); let resourceMethodsContent = fs.readFileSync(resourceMethodsPath, 'utf8'); // Add import for the operation method if (!resourceMethodsContent.includes(`import { ${operationName} }`)) { const importPos = resourceMethodsContent.indexOf('// Import operation methods here'); const importLine = `import { ${operationName} } from './operations/${operationName}.methods';\n`; resourceMethodsContent = resourceMethodsContent.slice(0, importPos) + importLine + resourceMethodsContent.slice(importPos); } // Add operation to the operations object const opsPos = resourceMethodsContent.indexOf('export const'); const opBracketPos = resourceMethodsContent.indexOf('{', opsPos); const opCloseBracketPos = resourceMethodsContent.indexOf('};', opBracketPos); const operationLine = ` ${operationName},\n`; resourceMethodsContent = resourceMethodsContent.slice(0, opCloseBracketPos) + operationLine + resourceMethodsContent.slice(opCloseBracketPos); fs.writeFileSync(resourceMethodsPath, resourceMethodsContent); console.log(`Generated operation: ${operationName}`); } // Example usage const domainName = process.argv[2]; const resourceName = process.argv[3]; const operationName = process.argv[4]; const method = process.argv[5] || 'GET'; const endpoint = process.argv[6] || `/api/v2/${resourceName}`; const paramsStr = process.argv[7] || '[]'; try { const params = JSON.parse(paramsStr); if (!domainName || !resourceName || !operationName) { console.error('Usage: node generate-operations.js domainName resourceName operationName [method] [endpoint] [params]'); process.exit(1); } generateOperation(domainName, resourceName, operationName, method, endpoint, params); } catch (error) { console.error('Error:', error); process.exit(1); } ``` ## Workflow for Implementing a New Domain Follow this process to implement a new domain: 1. **Analyze API Documentation** ```bash node scripts/parse-api-docs.js monitoring ``` 2. **Generate Domain Structure** ```bash node scripts/generate-domain.js monitoring ap client site ``` 3. **Generate Operations for Each Resource** ```bash node scripts/generate-operations.js monitoring site getSites GET "/monitoring/v1/sites" '[{"name":"limit","type":"number","description":"Max number of results"},{"name":"offset","type":"number","description":"Results offset"}]' ``` 4. **Update Domain Reference in api/descriptions.ts** - Import the new domain descriptions - Add the domain to the domain selector options - Add the domain's descriptions to the allDescriptions array 5. **Implement Operation Logic** - Refine the generated operation methods with specific logic - Add proper error handling and response formatting - Add type definitions based on API responses ## Best Practices for Implementation 1. **Type Safety** - Define interfaces for all request and response structures - Use validation functions to verify API responses - Provide type guards for complex response handling 2. **Operation Naming** - Use camelCase for operation names (`getSites`, `createUser`) - Follow consistent operation type prefixes (get, list, create, update, delete) - Group similar operations together 3. **Parameter Organization** - Required parameters should be top-level - Optional parameters should go in "Additional Fields" collections - Use appropriate n8n parameter types (string, number, boolean, options, multiOptions) 4. **Error Handling** - Use the `handleApiError` helper for consistent error formatting - Add domain, resource, and operation context to all error logs - Provide user-friendly error messages 5. **Code Documentation** - Add JSDoc comments to all functions - Document parameters and return types - Include examples for complex operations ## Maintenance Approach To maintain and extend the integration over time: 1. **Keep Domain Separation** - Each API domain gets its own directory - Each resource gets its own subdirectory - Each operation gets its own files 2. **Update Versions** - When the API version changes, update the endpoint paths - Consider adding version-specific handlers for backwards compatibility - Document version compatibility in operation comments 3. **Track API Changes** - Use a changelog to document API changes - Add deprecation notices for endpoints that will be removed - Provide migration paths for users 4. **Test Coverage** - Unit test individual operations - Integration test resource and domain combinations - End-to-end test critical paths By following this structure and process, you can efficiently implement the remaining 30+ API domains in a consistent, maintainable way that scales with the complexity of the Aruba Central API.