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
Markdown
# 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.