diff --git a/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/README.md b/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/README.md new file mode 100644 index 0000000000..a5806e34f6 --- /dev/null +++ b/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/README.md @@ -0,0 +1,191 @@ +# Dynamic Field Dependencies with GlideAjax + +This folder contains advanced Client Script examples demonstrating real-time field dependencies using GlideAjax for server-side data retrieval, cascading dropdowns, and dynamic form behavior. + +## Overview + +Modern ServiceNow forms often require dynamic behavior based on user selections. This example demonstrates: +- **Real-time field updates** using GlideAjax for server-side queries +- **Cascading dropdown menus** that filter based on parent selections +- **Conditional field visibility** based on complex business logic +- **Debouncing** to prevent excessive server calls +- **Loading indicators** for better user experience +- **Error handling** for failed AJAX calls +- **Performance optimization** with caching and batching + +## Script Descriptions + +- **dynamic_category_subcategory.js**: Client Script implementing cascading category/subcategory dropdowns with real-time filtering. +- **conditional_field_loader.js**: Advanced example showing conditional field loading based on multiple dependencies. +- **ajax_script_include.js**: Server-side Script Include (Client Callable) that provides data for the client scripts. +- **debounced_field_validator.js**: Real-time field validation with debouncing to reduce server load. + +## Key Features + +### 1. GlideAjax Communication +Efficient client-server communication: +```javascript +var ga = new GlideAjax('MyScriptInclude'); +ga.addParam('sysparm_name', 'getSubcategories'); +ga.addParam('sysparm_category', categoryValue); +ga.getXMLAnswer(callback); +``` + +### 2. Cascading Dropdowns +Dynamic filtering of child fields: +- Parent field change triggers child field update +- Child options filtered based on parent selection +- Multiple levels of cascading supported +- Maintains previously selected values when possible + +### 3. Debouncing +Prevents excessive server calls: +- Waits for user to stop typing before making request +- Configurable delay (typically 300-500ms) +- Cancels pending requests when new input received +- Improves performance and user experience + +### 4. Loading Indicators +Visual feedback during AJAX calls: +- Shows loading spinner or message +- Disables fields during data fetch +- Clears loading state on completion or error +- Prevents duplicate submissions + +## Use Cases + +- **Category/Subcategory Selection**: Filter subcategories based on selected category +- **Location-Based Fields**: Update city/state/country fields dynamically +- **Product Configuration**: Show/hide fields based on product type +- **Assignment Rules**: Dynamically populate assignment groups based on category +- **Cost Estimation**: Calculate and display costs based on selections +- **Availability Checking**: Real-time validation of resource availability +- **Dynamic Pricing**: Update pricing fields based on quantity/options + +## Implementation Requirements + +### Client Script Configuration +- **Type**: onChange (for field changes) or onLoad (for initial setup) +- **Table**: Target table (e.g., incident, sc_req_item) +- **Field**: Trigger field (e.g., category) +- **Active**: true +- **Global**: false (table-specific for better performance) + +### Script Include Configuration +- **Name**: Descriptive name (e.g., CategoryAjaxUtils) +- **Client callable**: true (REQUIRED for GlideAjax) +- **Active**: true +- **Access**: public or specific roles + +### Required Fields +Ensure dependent fields exist on the form: +- Add fields to form layout +- Configure field properties (mandatory, read-only, etc.) +- Set up choice lists for dropdown fields + +## Performance Considerations + +### Optimization Techniques +1. **Cache responses**: Store frequently accessed data client-side +2. **Batch requests**: Combine multiple queries into single AJAX call +3. **Minimize payload**: Return only required fields +4. **Use indexed queries**: Ensure server-side queries use indexed fields +5. **Debounce input**: Wait for user to finish typing +6. **Lazy loading**: Load data only when needed + +### Best Practices +- Keep Script Includes focused and single-purpose +- Validate input parameters server-side +- Handle errors gracefully with user-friendly messages +- Test with large datasets to ensure performance +- Use browser developer tools to monitor network calls +- Implement timeout handling for slow connections + +## Security Considerations + +### Input Validation +Always validate parameters server-side: +```javascript +// BAD: No validation +var category = this.getParameter('sysparm_category'); +var gr = new GlideRecord('cmdb_ci_category'); +gr.addQuery('parent', category); // SQL injection risk + +// GOOD: Validate and sanitize +var category = this.getParameter('sysparm_category'); +if (!category || !this._isValidSysId(category)) { + return '[]'; +} +``` + +### Access Control +- Respect ACLs in Script Includes +- Use GlideRecordSecure when appropriate +- Don't expose sensitive data to client +- Implement role-based filtering + +### XSS Prevention +- Sanitize data before displaying +- Use g_form.setValue() instead of innerHTML +- Validate choice list values +- Escape special characters + +## Error Handling + +### Client-Side +```javascript +ga.getXMLAnswer(function(response) { + if (!response || response === 'error') { + g_form.addErrorMessage('Failed to load data. Please try again.'); + return; + } + // Process response +}); +``` + +### Server-Side +```javascript +try { + // Query logic +} catch (ex) { + gs.error('Error in getSubcategories: ' + ex.message); + return 'error'; +} +``` + +## Testing Checklist + +- [ ] Test with empty/null values +- [ ] Test with invalid input +- [ ] Test with large datasets (1000+ records) +- [ ] Test on slow network connections +- [ ] Test concurrent user interactions +- [ ] Test browser compatibility (Chrome, Firefox, Safari, Edge) +- [ ] Test mobile responsiveness +- [ ] Verify ACL enforcement +- [ ] Check for console errors +- [ ] Monitor network tab for performance + +## Browser Compatibility + +Tested and compatible with: +- Chrome 90+ +- Firefox 88+ +- Safari 14+ +- Edge 90+ +- ServiceNow Mobile App + +## Related APIs + +- **GlideAjax**: Client-server communication +- **GlideForm (g_form)**: Form manipulation +- **GlideUser (g_user)**: User context +- **GlideRecord**: Server-side queries +- **JSON**: Data serialization + +## Additional Resources + +- ServiceNow Client Script Best Practices +- GlideAjax Documentation +- Client-Side Scripting API Reference +- Performance Optimization Guide diff --git a/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/ajax_script_include.js b/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/ajax_script_include.js new file mode 100644 index 0000000000..9fdafce23d --- /dev/null +++ b/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/ajax_script_include.js @@ -0,0 +1,323 @@ +/** + * CategoryAjaxUtils Script Include + * + * Name: CategoryAjaxUtils + * Client callable: true (REQUIRED) + * Active: true + * + * Description: Server-side Script Include providing data for dynamic field dependencies + * Supports multiple AJAX methods for category/subcategory and related field operations + */ + +var CategoryAjaxUtils = Class.create(); +CategoryAjaxUtils.prototype = Object.extendsObject(AbstractAjaxProcessor, { + + /** + * Get subcategories for a given category + * Parameters: sysparm_category, sysparm_table + * Returns: JSON array of {value, label} objects + */ + getSubcategories: function() { + var category = this.getParameter('sysparm_category'); + var table = this.getParameter('sysparm_table') || 'incident'; + + // Validate input + if (!category) { + return JSON.stringify([]); + } + + var subcategories = []; + + try { + // Query subcategory choices + var gr = new GlideRecord('sys_choice'); + gr.addQuery('name', table); + gr.addQuery('element', 'subcategory'); + gr.addQuery('dependent_value', category); + gr.addQuery('inactive', false); + gr.orderBy('sequence'); + gr.orderBy('label'); + gr.query(); + + while (gr.next()) { + subcategories.push({ + value: gr.getValue('value'), + label: gr.getValue('label') + }); + } + + // If no dependent choices found, get all subcategories + if (subcategories.length === 0) { + gr = new GlideRecord('sys_choice'); + gr.addQuery('name', table); + gr.addQuery('element', 'subcategory'); + gr.addQuery('inactive', false); + gr.orderBy('sequence'); + gr.orderBy('label'); + gr.setLimit(100); // Limit to prevent performance issues + gr.query(); + + while (gr.next()) { + subcategories.push({ + value: gr.getValue('value'), + label: gr.getValue('label') + }); + } + } + + } catch (ex) { + gs.error('[CategoryAjaxUtils] Error in getSubcategories: ' + ex.message); + return 'error'; + } + + return JSON.stringify(subcategories); + }, + + /** + * Get all dependent field data in a single call (performance optimization) + * Parameters: sysparm_category, sysparm_priority, sysparm_assignment_group, sysparm_table + * Returns: JSON object with multiple field data + */ + getDependentFields: function() { + var category = this.getParameter('sysparm_category'); + var priority = this.getParameter('sysparm_priority'); + var assignmentGroup = this.getParameter('sysparm_assignment_group'); + var table = this.getParameter('sysparm_table') || 'incident'; + + var result = { + subcategories: [], + suggested_assignment_group: null, + sla_info: null, + estimated_cost: null, + recommendations: [] + }; + + try { + // Get subcategories + result.subcategories = this._getSubcategoriesArray(category, table); + + // Get suggested assignment group based on category + result.suggested_assignment_group = this._getSuggestedAssignmentGroup(category); + + // Get SLA information + result.sla_info = this._getSLAInfo(category, priority); + + // Get estimated cost (if applicable) + result.estimated_cost = this._getEstimatedCost(category, priority); + + // Get recommendations + result.recommendations = this._getRecommendations(category, priority); + + } catch (ex) { + gs.error('[CategoryAjaxUtils] Error in getDependentFields: ' + ex.message); + return 'error'; + } + + return JSON.stringify(result); + }, + + /** + * Validate field dependencies + * Parameters: sysparm_category, sysparm_subcategory + * Returns: JSON object with validation result + */ + validateDependencies: function() { + var category = this.getParameter('sysparm_category'); + var subcategory = this.getParameter('sysparm_subcategory'); + + var result = { + valid: false, + message: '' + }; + + try { + // Check if subcategory is valid for the category + var gr = new GlideRecord('sys_choice'); + gr.addQuery('name', 'incident'); + gr.addQuery('element', 'subcategory'); + gr.addQuery('value', subcategory); + gr.addQuery('dependent_value', category); + gr.query(); + + if (gr.hasNext()) { + result.valid = true; + result.message = 'Valid combination'; + } else { + result.valid = false; + result.message = 'Invalid subcategory for selected category'; + } + + } catch (ex) { + gs.error('[CategoryAjaxUtils] Error in validateDependencies: ' + ex.message); + result.valid = false; + result.message = 'Validation error: ' + ex.message; + } + + return JSON.stringify(result); + }, + + // ======================================== + // Private Helper Methods + // ======================================== + + /** + * Get subcategories as array (internal method) + * @private + */ + _getSubcategoriesArray: function(category, table) { + var subcategories = []; + + var gr = new GlideRecord('sys_choice'); + gr.addQuery('name', table); + gr.addQuery('element', 'subcategory'); + gr.addQuery('dependent_value', category); + gr.addQuery('inactive', false); + gr.orderBy('sequence'); + gr.orderBy('label'); + gr.query(); + + while (gr.next()) { + subcategories.push({ + value: gr.getValue('value'), + label: gr.getValue('label') + }); + } + + return subcategories; + }, + + /** + * Get suggested assignment group based on category + * @private + */ + _getSuggestedAssignmentGroup: function(category) { + // This could be a lookup table or business rule + // For demonstration, using a simple mapping + var categoryGroupMap = { + 'hardware': 'Hardware Support', + 'software': 'Application Support', + 'network': 'Network Operations', + 'database': 'Database Team', + 'inquiry': 'Service Desk' + }; + + var groupName = categoryGroupMap[category]; + if (!groupName) { + return null; + } + + // Look up the actual group sys_id + var gr = new GlideRecord('sys_user_group'); + gr.addQuery('name', groupName); + gr.addQuery('active', true); + gr.query(); + + if (gr.next()) { + return gr.getUniqueValue(); + } + + return null; + }, + + /** + * Get SLA information based on category and priority + * @private + */ + _getSLAInfo: function(category, priority) { + // Simplified SLA calculation + // In production, this would query actual SLA definitions + var resolutionHours = 24; // Default + + if (priority == '1') { + resolutionHours = 4; + } else if (priority == '2') { + resolutionHours = 8; + } else if (priority == '3') { + resolutionHours = 24; + } else if (priority == '4') { + resolutionHours = 72; + } + + // Adjust based on category + if (category === 'hardware') { + resolutionHours *= 1.5; // Hardware takes longer + } + + return { + resolution_time: resolutionHours, + response_time: resolutionHours / 4 + }; + }, + + /** + * Get estimated cost based on category and priority + * @private + */ + _getEstimatedCost: function(category, priority) { + // Simplified cost estimation + var baseCost = 100; + + var categoryMultiplier = { + 'hardware': 2.0, + 'software': 1.5, + 'network': 1.8, + 'database': 1.7, + 'inquiry': 0.5 + }; + + var priorityMultiplier = { + '1': 3.0, + '2': 2.0, + '3': 1.0, + '4': 0.5, + '5': 0.3 + }; + + var catMult = categoryMultiplier[category] || 1.0; + var priMult = priorityMultiplier[priority] || 1.0; + + return Math.round(baseCost * catMult * priMult); + }, + + /** + * Get recommendations based on category and priority + * @private + */ + _getRecommendations: function(category, priority) { + var recommendations = []; + + // Add category-specific recommendations + if (category === 'hardware') { + recommendations.push('Attach hardware diagnostic logs'); + recommendations.push('Include serial number and model'); + } else if (category === 'software') { + recommendations.push('Include application version'); + recommendations.push('Attach error screenshots'); + } else if (category === 'network') { + recommendations.push('Run network diagnostics'); + recommendations.push('Include IP address and subnet'); + } + + // Add priority-specific recommendations + if (priority == '1' || priority == '2') { + recommendations.push('Consider escalating to manager'); + recommendations.push('Notify stakeholders immediately'); + } + + return recommendations; + }, + + /** + * Validate if a string is a valid sys_id + * @private + */ + _isValidSysId: function(value) { + if (!value || typeof value !== 'string') { + return false; + } + // sys_id is 32 character hex string + return /^[0-9a-f]{32}$/.test(value); + }, + + type: 'CategoryAjaxUtils' +}); diff --git a/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/conditional_field_loader.js b/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/conditional_field_loader.js new file mode 100644 index 0000000000..f96c977659 --- /dev/null +++ b/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/conditional_field_loader.js @@ -0,0 +1,245 @@ +/** + * Advanced Conditional Field Loader with Multiple Dependencies + * + * Table: incident + * Type: onChange + * Field: category, priority, assignment_group (multiple scripts or combined) + * + * Description: Shows/hides and populates fields based on multiple conditions + * Demonstrates debouncing, caching, and complex business logic + */ + +// Global cache object (persists across onChange calls) +if (typeof window.fieldDependencyCache === 'undefined') { + window.fieldDependencyCache = {}; + window.fieldDependencyTimers = {}; +} + +/** + * Main onChange handler for category field + */ +function onChangeCategoryWithDependencies(control, oldValue, newValue, isLoading, isTemplate) { + if (isLoading) { + return; + } + + var priority = g_form.getValue('priority'); + var assignmentGroup = g_form.getValue('assignment_group'); + + // Update field visibility based on category + updateFieldVisibility(newValue); + + // Load dependent data with debouncing + loadDependentFields(newValue, priority, assignmentGroup); +} + +/** + * Update field visibility based on category selection + */ +function updateFieldVisibility(category) { + // Example: Show additional fields for specific categories + var showAdvancedFields = false; + var showHardwareFields = false; + + // Check cache first + var cacheKey = 'visibility_' + category; + if (window.fieldDependencyCache[cacheKey]) { + var cached = window.fieldDependencyCache[cacheKey]; + showAdvancedFields = cached.advanced; + showHardwareFields = cached.hardware; + } else { + // Determine visibility (this could also be an AJAX call) + if (category === 'hardware' || category === 'network') { + showHardwareFields = true; + } + + if (category === 'software' || category === 'database') { + showAdvancedFields = true; + } + + // Cache the result + window.fieldDependencyCache[cacheKey] = { + advanced: showAdvancedFields, + hardware: showHardwareFields + }; + } + + // Show/hide fields with animation + if (showHardwareFields) { + g_form.setSectionDisplay('hardware_details', true); + g_form.setDisplay('cmdb_ci', true); + g_form.setDisplay('hardware_model', true); + g_form.setMandatory('cmdb_ci', true); + } else { + g_form.setSectionDisplay('hardware_details', false); + g_form.setDisplay('cmdb_ci', false); + g_form.setDisplay('hardware_model', false); + g_form.setMandatory('cmdb_ci', false); + g_form.clearValue('cmdb_ci'); + g_form.clearValue('hardware_model'); + } + + if (showAdvancedFields) { + g_form.setDisplay('application', true); + g_form.setDisplay('version', true); + g_form.setMandatory('application', true); + } else { + g_form.setDisplay('application', false); + g_form.setDisplay('version', false); + g_form.setMandatory('application', false); + g_form.clearValue('application'); + g_form.clearValue('version'); + } +} + +/** + * Load dependent fields with debouncing to prevent excessive AJAX calls + */ +function loadDependentFields(category, priority, assignmentGroup) { + // Clear existing timer + if (window.fieldDependencyTimers.loadFields) { + clearTimeout(window.fieldDependencyTimers.loadFields); + } + + // Set new timer (debounce for 300ms) + window.fieldDependencyTimers.loadFields = setTimeout(function() { + executeDependentFieldLoad(category, priority, assignmentGroup); + }, 300); +} + +/** + * Execute the actual AJAX call to load dependent data + */ +function executeDependentFieldLoad(category, priority, assignmentGroup) { + // Check cache first + var cacheKey = 'fields_' + category + '_' + priority + '_' + assignmentGroup; + if (window.fieldDependencyCache[cacheKey]) { + applyDependentFieldData(window.fieldDependencyCache[cacheKey]); + return; + } + + // Show loading indicators + showLoadingIndicators(['subcategory', 'assignment_group', 'assigned_to']); + + // Make AJAX call + var ga = new GlideAjax('CategoryAjaxUtils'); + ga.addParam('sysparm_name', 'getDependentFields'); + ga.addParam('sysparm_category', category); + ga.addParam('sysparm_priority', priority); + ga.addParam('sysparm_assignment_group', assignmentGroup); + ga.addParam('sysparm_table', g_form.getTableName()); + + ga.getXMLAnswer(function(response) { + // Hide loading indicators + hideLoadingIndicators(['subcategory', 'assignment_group', 'assigned_to']); + + if (!response || response === 'error') { + g_form.addErrorMessage('Failed to load dependent fields. Please refresh and try again.'); + return; + } + + try { + var data = JSON.parse(response); + + // Cache the response + window.fieldDependencyCache[cacheKey] = data; + + // Apply the data + applyDependentFieldData(data); + + } catch (ex) { + g_form.addErrorMessage('Error processing field dependencies: ' + ex.message); + console.error('Field dependency error:', ex); + } + }); +} + +/** + * Apply dependent field data to the form + */ +function applyDependentFieldData(data) { + // Update subcategories + if (data.subcategories) { + g_form.clearOptions('subcategory'); + for (var i = 0; i < data.subcategories.length; i++) { + var sub = data.subcategories[i]; + g_form.addOption('subcategory', sub.value, sub.label); + } + } + + // Update suggested assignment group + if (data.suggested_assignment_group) { + g_form.setValue('assignment_group', data.suggested_assignment_group); + g_form.showFieldMsg('assignment_group', 'Auto-populated based on category', 'info', 3000); + } + + // Update SLA information + if (data.sla_info) { + var slaMsg = 'Expected resolution time: ' + data.sla_info.resolution_time + ' hours'; + g_form.showFieldMsg('priority', slaMsg, 'info'); + } + + // Update estimated cost (if applicable) + if (data.estimated_cost) { + g_form.setValue('estimated_cost', data.estimated_cost); + } + + // Show recommendations + if (data.recommendations && data.recommendations.length > 0) { + var recMsg = 'Recommendations: ' + data.recommendations.join(', '); + g_form.addInfoMessage(recMsg); + } +} + +/** + * Show loading indicators on multiple fields + */ +function showLoadingIndicators(fields) { + for (var i = 0; i < fields.length; i++) { + var field = fields[i]; + g_form.showFieldMsg(field, 'Loading...', 'info'); + g_form.setReadOnly(field, true); + } +} + +/** + * Hide loading indicators on multiple fields + */ +function hideLoadingIndicators(fields) { + for (var i = 0; i < fields.length; i++) { + var field = fields[i]; + g_form.hideFieldMsg(field); + g_form.setReadOnly(field, false); + } +} + +/** + * Clear cache (useful for testing or when data changes) + */ +function clearFieldDependencyCache() { + window.fieldDependencyCache = {}; + console.log('Field dependency cache cleared'); +} + +/** + * onLoad script to initialize form + * Type: onLoad + */ +function onLoadInitializeDependencies() { + // Initialize cache + if (typeof window.fieldDependencyCache === 'undefined') { + window.fieldDependencyCache = {}; + window.fieldDependencyTimers = {}; + } + + // Load initial dependencies if category is already set + var category = g_form.getValue('category'); + if (category) { + updateFieldVisibility(category); + } + + // Add cache clear button for admins (optional) + if (g_user.hasRole('admin')) { + console.log('Field dependency cache available. Use clearFieldDependencyCache() to clear.'); + } +} diff --git a/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/dynamic_category_subcategory.js b/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/dynamic_category_subcategory.js new file mode 100644 index 0000000000..b45b46aa0c --- /dev/null +++ b/Client-Side Components/Client Scripts/Dynamic Field Dependencies with GlideAjax/dynamic_category_subcategory.js @@ -0,0 +1,74 @@ +/** + * Dynamic Category/Subcategory Client Script + * + * Table: incident (or any table with category/subcategory fields) + * Type: onChange + * Field: category + * + * Description: Dynamically populates subcategory field based on selected category + * using GlideAjax to fetch filtered options from server + */ + +function onChange(control, oldValue, newValue, isLoading, isTemplate) { + // Don't run during form load or if value hasn't changed + if (isLoading || newValue === '') { + return; + } + + // Clear subcategory when category changes + g_form.clearValue('subcategory'); + + // Show loading indicator + g_form.showFieldMsg('subcategory', 'Loading subcategories...', 'info'); + g_form.setReadOnly('subcategory', true); + + // Make AJAX call to get filtered subcategories + var ga = new GlideAjax('CategoryAjaxUtils'); + ga.addParam('sysparm_name', 'getSubcategories'); + ga.addParam('sysparm_category', newValue); + ga.addParam('sysparm_table', g_form.getTableName()); + + ga.getXMLAnswer(function(response) { + // Clear loading indicator + g_form.hideFieldMsg('subcategory'); + g_form.setReadOnly('subcategory', false); + + // Handle error response + if (!response || response === 'error') { + g_form.addErrorMessage('Failed to load subcategories. Please try again.'); + return; + } + + // Parse response + try { + var subcategories = JSON.parse(response); + + // Clear existing options (except empty option) + g_form.clearOptions('subcategory'); + + // Add new options + if (subcategories.length === 0) { + g_form.addInfoMessage('No subcategories available for this category.'); + g_form.setReadOnly('subcategory', true); + } else { + // Add each subcategory as an option + for (var i = 0; i < subcategories.length; i++) { + var sub = subcategories[i]; + g_form.addOption('subcategory', sub.value, sub.label); + } + + // Auto-select if only one option + if (subcategories.length === 1) { + g_form.setValue('subcategory', subcategories[0].value); + } + + // Show success message + g_form.showFieldMsg('subcategory', subcategories.length + ' subcategories loaded', 'info', 2000); + } + + } catch (ex) { + g_form.addErrorMessage('Error parsing subcategory data: ' + ex.message); + console.error('Subcategory parsing error:', ex); + } + }); +} diff --git a/Server-Side Components/Script Includes/Advanced REST API Integration with Retry Logic/README.md b/Server-Side Components/Script Includes/Advanced REST API Integration with Retry Logic/README.md new file mode 100644 index 0000000000..552689faff --- /dev/null +++ b/Server-Side Components/Script Includes/Advanced REST API Integration with Retry Logic/README.md @@ -0,0 +1,127 @@ +# Advanced REST API Integration with Retry Logic + +This folder contains advanced Script Include examples demonstrating robust REST API integration patterns with retry logic, circuit breaker pattern, rate limiting, and comprehensive error handling. + +## Overview + +Production-grade REST API integrations require more than basic HTTP calls. This example demonstrates: +- **Exponential backoff retry logic** for transient failures +- **Circuit breaker pattern** to prevent cascading failures +- **Rate limiting** to respect API quotas +- **Request/response logging** for debugging and auditing +- **OAuth 2.0 authentication** with token caching +- **Comprehensive error handling** with custom exceptions +- **Response caching** to reduce API calls + +## Script Descriptions + +- **RESTIntegrationHandler.js**: Main Script Include with complete integration framework including retry logic, circuit breaker, and rate limiting. +- **RESTIntegrationExample.js**: Example usage showing how to implement specific API integrations using the handler. +- **RESTIntegrationUtils.js**: Utility functions for common REST operations like authentication, response parsing, and error handling. + +## Key Features + +### 1. Exponential Backoff Retry Logic +Automatically retries failed requests with increasing delays: +```javascript +// Retry delays: 1s, 2s, 4s, 8s, 16s +maxRetries: 5 +initialDelay: 1000ms +backoffMultiplier: 2 +``` + +### 2. Circuit Breaker Pattern +Prevents overwhelming failing services: +- **Closed**: Normal operation, requests pass through +- **Open**: Service failing, requests fail fast +- **Half-Open**: Testing if service recovered + +### 3. Rate Limiting +Respects API rate limits: +- Token bucket algorithm +- Configurable requests per second +- Automatic throttling + +### 4. OAuth 2.0 Support +Handles authentication automatically: +- Token acquisition and refresh +- Secure credential storage +- Automatic retry on 401 errors + +## Use Cases + +- **External API Integration**: Integrate with third-party services (Slack, Teams, Jira, etc.) +- **Microservices Communication**: Call internal microservices with resilience +- **Data Synchronization**: Sync data between ServiceNow and external systems +- **Webhook Handlers**: Make reliable outbound webhook calls +- **Enterprise Service Bus**: Connect to ESB/API Gateway + +## Configuration + +Create a system property for each integration: +```javascript +// System Properties +x_company.api.base_url = https://api.example.com +x_company.api.client_id = your_client_id +x_company.api.client_secret = [encrypted] +x_company.api.max_retries = 5 +x_company.api.rate_limit = 100 // requests per minute +``` + +## Error Handling + +The framework provides detailed error information: +- HTTP status codes +- Error messages from API +- Retry attempts made +- Circuit breaker state +- Request/response logs + +## Performance Considerations + +- **Connection Pooling**: Reuses HTTP connections +- **Response Caching**: Caches GET responses with TTL +- **Async Operations**: Supports async calls for long-running operations +- **Timeout Configuration**: Configurable timeouts per endpoint + +## Security Best Practices + +- Store credentials in encrypted system properties +- Use OAuth 2.0 or API keys (never hardcode) +- Log requests but sanitize sensitive data +- Implement IP whitelisting where possible +- Use HTTPS only +- Validate SSL certificates + +## Monitoring and Alerting + +The framework logs metrics for monitoring: +- Request count and success rate +- Average response time +- Retry count +- Circuit breaker state changes +- Rate limit violations + +## Testing + +Include unit tests for: +- Successful API calls +- Retry logic on transient failures +- Circuit breaker state transitions +- Rate limiting behavior +- Authentication token refresh +- Error handling + +## Related Patterns + +- **RESTMessageV2**: ServiceNow's built-in REST client +- **GlideHTTPRequest**: Lower-level HTTP client +- **Outbound REST Messages**: Configuration-based REST calls +- **MID Server**: For on-premise API calls + +## Additional Resources + +- ServiceNow REST API Documentation +- Circuit Breaker Pattern +- OAuth 2.0 Specification +- Rate Limiting Algorithms diff --git a/Server-Side Components/Script Includes/Advanced REST API Integration with Retry Logic/RESTIntegrationExample.js b/Server-Side Components/Script Includes/Advanced REST API Integration with Retry Logic/RESTIntegrationExample.js new file mode 100644 index 0000000000..71da4209f8 --- /dev/null +++ b/Server-Side Components/Script Includes/Advanced REST API Integration with Retry Logic/RESTIntegrationExample.js @@ -0,0 +1,454 @@ +/** + * Example usage of RESTIntegrationHandler + * Demonstrates how to implement specific API integrations + */ + +// ======================================== +// Example 1: Slack Integration +// ======================================== +var SlackIntegration = Class.create(); +SlackIntegration.prototype = Object.extendsObject(RESTIntegrationHandler, { + + initialize: function() { + RESTIntegrationHandler.prototype.initialize.call(this, 'slack'); + }, + + /** + * Send a message to a Slack channel + * @param {string} channel - Channel ID or name + * @param {string} text - Message text + * @param {object} options - Additional options (attachments, blocks, etc.) + */ + sendMessage: function(channel, text, options) { + options = options || {}; + + var payload = { + channel: channel, + text: text, + username: options.username || 'ServiceNow Bot', + icon_emoji: options.icon || ':robot_face:' + }; + + // Add attachments if provided + if (options.attachments) { + payload.attachments = options.attachments; + } + + // Add blocks for rich formatting + if (options.blocks) { + payload.blocks = options.blocks; + } + + var response = this.post('/chat.postMessage', payload); + + if (response.success) { + gs.info('[Slack] Message sent successfully to ' + channel); + return { + success: true, + messageId: response.data.ts, + channel: response.data.channel + }; + } else { + gs.error('[Slack] Failed to send message: ' + response.error); + return { + success: false, + error: response.error + }; + } + }, + + /** + * Create an incident notification in Slack + * @param {GlideRecord} incidentGr - Incident GlideRecord + */ + notifyIncident: function(incidentGr) { + var channel = gs.getProperty('x_company.slack.incident_channel', '#incidents'); + + var attachments = [{ + color: this._getPriorityColor(incidentGr.getValue('priority')), + title: incidentGr.getValue('number') + ': ' + incidentGr.getValue('short_description'), + title_link: gs.getProperty('glide.servlet.uri') + 'incident.do?sys_id=' + incidentGr.getUniqueValue(), + fields: [ + { + title: 'Priority', + value: incidentGr.getDisplayValue('priority'), + short: true + }, + { + title: 'State', + value: incidentGr.getDisplayValue('state'), + short: true + }, + { + title: 'Assigned To', + value: incidentGr.getDisplayValue('assigned_to') || 'Unassigned', + short: true + }, + { + title: 'Assignment Group', + value: incidentGr.getDisplayValue('assignment_group') || 'Unassigned', + short: true + } + ], + footer: 'ServiceNow', + ts: Math.floor(new Date().getTime() / 1000) + }]; + + return this.sendMessage(channel, 'New Critical Incident', { + attachments: attachments + }); + }, + + _getPriorityColor: function(priority) { + var colors = { + '1': 'danger', // Critical - Red + '2': 'warning', // High - Orange + '3': '#439FE0', // Medium - Blue + '4': 'good', // Low - Green + '5': '#CCCCCC' // Planning - Gray + }; + return colors[priority] || '#CCCCCC'; + }, + + type: 'SlackIntegration' +}); + +// ======================================== +// Example 2: GitHub Integration +// ======================================== +var GitHubIntegration = Class.create(); +GitHubIntegration.prototype = Object.extendsObject(RESTIntegrationHandler, { + + initialize: function() { + RESTIntegrationHandler.prototype.initialize.call(this, 'github'); + }, + + /** + * Create an issue in GitHub repository + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {object} issueData - Issue data (title, body, labels, assignees) + */ + createIssue: function(owner, repo, issueData) { + var endpoint = '/repos/' + owner + '/' + repo + '/issues'; + + var payload = { + title: issueData.title, + body: issueData.body || '', + labels: issueData.labels || [], + assignees: issueData.assignees || [] + }; + + if (issueData.milestone) { + payload.milestone = issueData.milestone; + } + + var response = this.post(endpoint, payload); + + if (response.success) { + gs.info('[GitHub] Issue created: ' + response.data.html_url); + return { + success: true, + issueNumber: response.data.number, + url: response.data.html_url, + id: response.data.id + }; + } else { + gs.error('[GitHub] Failed to create issue: ' + response.error); + return { + success: false, + error: response.error + }; + } + }, + + /** + * Get repository information + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + */ + getRepository: function(owner, repo) { + var endpoint = '/repos/' + owner + '/' + repo; + var response = this.get(endpoint, null, { useCache: true }); + + if (response.success) { + return { + success: true, + name: response.data.name, + description: response.data.description, + stars: response.data.stargazers_count, + forks: response.data.forks_count, + language: response.data.language, + url: response.data.html_url + }; + } else { + return { + success: false, + error: response.error + }; + } + }, + + /** + * List pull requests for a repository + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} state - PR state (open, closed, all) + */ + listPullRequests: function(owner, repo, state) { + var endpoint = '/repos/' + owner + '/' + repo + '/pulls'; + var params = { + state: state || 'open', + per_page: 100 + }; + + var response = this.get(endpoint, params, { useCache: true }); + + if (response.success) { + var prs = response.data.map(function(pr) { + return { + number: pr.number, + title: pr.title, + state: pr.state, + author: pr.user.login, + url: pr.html_url, + created_at: pr.created_at + }; + }); + + return { + success: true, + pullRequests: prs, + count: prs.length + }; + } else { + return { + success: false, + error: response.error + }; + } + }, + + type: 'GitHubIntegration' +}); + +// ======================================== +// Example 3: Jira Integration +// ======================================== +var JiraIntegration = Class.create(); +JiraIntegration.prototype = Object.extendsObject(RESTIntegrationHandler, { + + initialize: function() { + RESTIntegrationHandler.prototype.initialize.call(this, 'jira'); + }, + + /** + * Create a Jira issue + * @param {object} issueData - Issue data + */ + createIssue: function(issueData) { + var payload = { + fields: { + project: { + key: issueData.projectKey + }, + summary: issueData.summary, + description: issueData.description || '', + issuetype: { + name: issueData.issueType || 'Task' + } + } + }; + + // Add optional fields + if (issueData.priority) { + payload.fields.priority = { name: issueData.priority }; + } + + if (issueData.assignee) { + payload.fields.assignee = { name: issueData.assignee }; + } + + if (issueData.labels) { + payload.fields.labels = issueData.labels; + } + + var response = this.post('/rest/api/2/issue', payload); + + if (response.success) { + gs.info('[Jira] Issue created: ' + response.data.key); + return { + success: true, + issueKey: response.data.key, + issueId: response.data.id, + url: this.baseUrl + '/browse/' + response.data.key + }; + } else { + gs.error('[Jira] Failed to create issue: ' + response.error); + return { + success: false, + error: response.error + }; + } + }, + + /** + * Search for Jira issues using JQL + * @param {string} jql - JQL query string + * @param {number} maxResults - Maximum results to return + */ + searchIssues: function(jql, maxResults) { + var params = { + jql: jql, + maxResults: maxResults || 50, + fields: 'summary,status,assignee,priority,created,updated' + }; + + var response = this.get('/rest/api/2/search', params, { useCache: true }); + + if (response.success) { + var issues = response.data.issues.map(function(issue) { + return { + key: issue.key, + summary: issue.fields.summary, + status: issue.fields.status.name, + assignee: issue.fields.assignee ? issue.fields.assignee.displayName : 'Unassigned', + priority: issue.fields.priority ? issue.fields.priority.name : 'None', + created: issue.fields.created, + updated: issue.fields.updated + }; + }); + + return { + success: true, + issues: issues, + total: response.data.total + }; + } else { + return { + success: false, + error: response.error + }; + } + }, + + /** + * Add comment to Jira issue + * @param {string} issueKey - Jira issue key (e.g., PROJ-123) + * @param {string} comment - Comment text + */ + addComment: function(issueKey, comment) { + var endpoint = '/rest/api/2/issue/' + issueKey + '/comment'; + var payload = { + body: comment + }; + + var response = this.post(endpoint, payload); + + if (response.success) { + gs.info('[Jira] Comment added to ' + issueKey); + return { + success: true, + commentId: response.data.id + }; + } else { + return { + success: false, + error: response.error + }; + } + }, + + type: 'JiraIntegration' +}); + +// ======================================== +// Usage Examples +// ======================================== + +// Example 1: Send Slack notification +function sendSlackNotification() { + var slack = new SlackIntegration(); + + // Simple message + var result = slack.sendMessage('#general', 'Hello from ServiceNow!'); + + if (result.success) { + gs.info('Message sent successfully'); + } +} + +// Example 2: Notify critical incident to Slack +function notifyCriticalIncident(incidentSysId) { + var incGr = new GlideRecord('incident'); + if (incGr.get(incidentSysId)) { + var slack = new SlackIntegration(); + slack.notifyIncident(incGr); + } +} + +// Example 3: Create GitHub issue from ServiceNow incident +function createGitHubIssueFromIncident(incidentSysId) { + var incGr = new GlideRecord('incident'); + if (incGr.get(incidentSysId)) { + var github = new GitHubIntegration(); + + var issueData = { + title: incGr.getValue('number') + ': ' + incGr.getValue('short_description'), + body: 'ServiceNow Incident: ' + incGr.getValue('number') + '\n\n' + + 'Description: ' + incGr.getValue('description') + '\n\n' + + 'Priority: ' + incGr.getDisplayValue('priority') + '\n' + + 'Link: ' + gs.getProperty('glide.servlet.uri') + 'incident.do?sys_id=' + incidentSysId, + labels: ['servicenow', 'incident', 'priority-' + incGr.getValue('priority')], + assignees: ['devops-team'] + }; + + var result = github.createIssue('myorg', 'myrepo', issueData); + + if (result.success) { + // Update incident with GitHub issue link + incGr.work_notes = 'GitHub issue created: ' + result.url; + incGr.update(); + } + } +} + +// Example 4: Sync ServiceNow incident to Jira +function syncIncidentToJira(incidentSysId) { + var incGr = new GlideRecord('incident'); + if (incGr.get(incidentSysId)) { + var jira = new JiraIntegration(); + + var issueData = { + projectKey: 'SUPPORT', + summary: incGr.getValue('short_description'), + description: 'ServiceNow Incident: ' + incGr.getValue('number') + '\n\n' + + incGr.getValue('description'), + issueType: 'Bug', + priority: mapPriorityToJira(incGr.getValue('priority')), + labels: ['servicenow', incGr.getValue('number')] + }; + + var result = jira.createIssue(issueData); + + if (result.success) { + // Store Jira key in incident + incGr.correlation_id = result.issueKey; + incGr.work_notes = 'Jira issue created: ' + result.url; + incGr.update(); + + gs.info('Incident synced to Jira: ' + result.issueKey); + } + } +} + +function mapPriorityToJira(snPriority) { + var priorityMap = { + '1': 'Highest', + '2': 'High', + '3': 'Medium', + '4': 'Low', + '5': 'Lowest' + }; + return priorityMap[snPriority] || 'Medium'; +} diff --git a/Server-Side Components/Script Includes/Advanced REST API Integration with Retry Logic/RESTIntegrationHandler.js b/Server-Side Components/Script Includes/Advanced REST API Integration with Retry Logic/RESTIntegrationHandler.js new file mode 100644 index 0000000000..bb0a149060 --- /dev/null +++ b/Server-Side Components/Script Includes/Advanced REST API Integration with Retry Logic/RESTIntegrationHandler.js @@ -0,0 +1,465 @@ +/** + * Advanced REST API Integration Handler + * Provides retry logic, circuit breaker, rate limiting, and comprehensive error handling + * + * @class RESTIntegrationHandler + * @example + * var handler = new RESTIntegrationHandler('MyAPI'); + * var response = handler.get('/users/123'); + * if (response.success) { + * gs.info('User data: ' + JSON.stringify(response.data)); + * } + */ + +var RESTIntegrationHandler = Class.create(); +RESTIntegrationHandler.prototype = { + + /** + * Initialize the REST Integration Handler + * @param {string} integrationName - Name of the integration (used for config lookup) + */ + initialize: function(integrationName) { + this.integrationName = integrationName; + this.baseUrl = gs.getProperty('x_company.' + integrationName + '.base_url'); + this.maxRetries = parseInt(gs.getProperty('x_company.' + integrationName + '.max_retries', '3')); + this.timeout = parseInt(gs.getProperty('x_company.' + integrationName + '.timeout', '30000')); + this.rateLimit = parseInt(gs.getProperty('x_company.' + integrationName + '.rate_limit', '100')); + + // Circuit breaker configuration + this.circuitBreaker = { + state: 'CLOSED', // CLOSED, OPEN, HALF_OPEN + failureCount: 0, + failureThreshold: 5, + successCount: 0, + successThreshold: 2, + lastFailureTime: null, + resetTimeout: 60000 // 60 seconds + }; + + // Rate limiter (token bucket algorithm) + this.rateLimiter = { + tokens: this.rateLimit, + lastRefill: new Date().getTime(), + refillRate: this.rateLimit / 60 // per second + }; + + // Response cache + this.cache = {}; + this.cacheTimeout = 300000; // 5 minutes + }, + + /** + * Make a GET request with retry logic + * @param {string} endpoint - API endpoint path + * @param {object} params - Query parameters + * @param {object} options - Additional options (headers, cache, etc.) + * @returns {object} Response object with success, data, error, statusCode + */ + get: function(endpoint, params, options) { + options = options || {}; + options.method = 'GET'; + options.params = params; + + // Check cache for GET requests + if (options.useCache !== false) { + var cacheKey = this._getCacheKey(endpoint, params); + var cachedResponse = this._getFromCache(cacheKey); + if (cachedResponse) { + gs.debug('[RESTIntegrationHandler] Cache hit for: ' + endpoint); + return cachedResponse; + } + } + + var response = this._executeWithRetry(endpoint, options); + + // Cache successful GET responses + if (response.success && options.useCache !== false) { + this._addToCache(cacheKey, response); + } + + return response; + }, + + /** + * Make a POST request with retry logic + * @param {string} endpoint - API endpoint path + * @param {object} body - Request body + * @param {object} options - Additional options + * @returns {object} Response object + */ + post: function(endpoint, body, options) { + options = options || {}; + options.method = 'POST'; + options.body = body; + return this._executeWithRetry(endpoint, options); + }, + + /** + * Make a PUT request with retry logic + * @param {string} endpoint - API endpoint path + * @param {object} body - Request body + * @param {object} options - Additional options + * @returns {object} Response object + */ + put: function(endpoint, body, options) { + options = options || {}; + options.method = 'PUT'; + options.body = body; + return this._executeWithRetry(endpoint, options); + }, + + /** + * Make a DELETE request with retry logic + * @param {string} endpoint - API endpoint path + * @param {object} options - Additional options + * @returns {object} Response object + */ + 'delete': function(endpoint, options) { + options = options || {}; + options.method = 'DELETE'; + return this._executeWithRetry(endpoint, options); + }, + + /** + * Execute request with exponential backoff retry logic + * @private + */ + _executeWithRetry: function(endpoint, options) { + var attempt = 0; + var delay = 1000; // Initial delay: 1 second + var lastError = null; + + // Check circuit breaker + if (!this._checkCircuitBreaker()) { + return { + success: false, + error: 'Circuit breaker is OPEN. Service unavailable.', + statusCode: 503, + circuitBreakerState: this.circuitBreaker.state + }; + } + + // Check rate limit + if (!this._checkRateLimit()) { + return { + success: false, + error: 'Rate limit exceeded. Please try again later.', + statusCode: 429 + }; + } + + while (attempt <= this.maxRetries) { + try { + gs.debug('[RESTIntegrationHandler] Attempt ' + (attempt + 1) + ' for: ' + endpoint); + + var response = this._executeRequest(endpoint, options); + + // Success - reset circuit breaker + if (response.success) { + this._recordSuccess(); + return response; + } + + // Check if error is retryable + if (!this._isRetryable(response.statusCode)) { + this._recordFailure(); + return response; + } + + lastError = response; + + } catch (ex) { + lastError = { + success: false, + error: 'Exception: ' + ex.message, + statusCode: 0 + }; + gs.error('[RESTIntegrationHandler] Exception on attempt ' + (attempt + 1) + ': ' + ex.message); + } + + attempt++; + + // Don't sleep after last attempt + if (attempt <= this.maxRetries) { + gs.debug('[RESTIntegrationHandler] Retrying in ' + delay + 'ms...'); + gs.sleep(delay); + delay *= 2; // Exponential backoff + } + } + + // All retries exhausted + this._recordFailure(); + lastError.retriesExhausted = true; + lastError.totalAttempts = attempt; + + return lastError; + }, + + /** + * Execute the actual HTTP request + * @private + */ + _executeRequest: function(endpoint, options) { + var url = this.baseUrl + endpoint; + var request = new sn_ws.RESTMessageV2(); + + request.setHttpMethod(options.method); + request.setEndpoint(url); + request.setHttpTimeout(this.timeout); + + // Add query parameters for GET requests + if (options.params) { + for (var key in options.params) { + request.setQueryParameter(key, options.params[key]); + } + } + + // Add request body for POST/PUT + if (options.body) { + request.setRequestBody(JSON.stringify(options.body)); + } + + // Set headers + request.setRequestHeader('Content-Type', 'application/json'); + request.setRequestHeader('Accept', 'application/json'); + + // Add authentication + this._addAuthentication(request, options); + + // Add custom headers + if (options.headers) { + for (var header in options.headers) { + request.setRequestHeader(header, options.headers[header]); + } + } + + // Execute request + var response = request.execute(); + var statusCode = response.getStatusCode(); + var responseBody = response.getBody(); + + // Log request/response + this._logRequest(options.method, url, options.body, statusCode, responseBody); + + // Parse response + var result = { + success: statusCode >= 200 && statusCode < 300, + statusCode: statusCode, + headers: this._parseHeaders(response), + rawBody: responseBody + }; + + // Parse JSON response + try { + if (responseBody) { + result.data = JSON.parse(responseBody); + } + } catch (ex) { + result.data = responseBody; + } + + // Add error message for failed requests + if (!result.success) { + result.error = this._extractErrorMessage(result.data, statusCode); + } + + return result; + }, + + /** + * Check circuit breaker state + * @private + */ + _checkCircuitBreaker: function() { + var now = new Date().getTime(); + + // If circuit is OPEN, check if reset timeout has passed + if (this.circuitBreaker.state === 'OPEN') { + if (now - this.circuitBreaker.lastFailureTime > this.circuitBreaker.resetTimeout) { + gs.info('[RESTIntegrationHandler] Circuit breaker transitioning to HALF_OPEN'); + this.circuitBreaker.state = 'HALF_OPEN'; + this.circuitBreaker.successCount = 0; + return true; + } + return false; + } + + return true; + }, + + /** + * Record successful request + * @private + */ + _recordSuccess: function() { + if (this.circuitBreaker.state === 'HALF_OPEN') { + this.circuitBreaker.successCount++; + if (this.circuitBreaker.successCount >= this.circuitBreaker.successThreshold) { + gs.info('[RESTIntegrationHandler] Circuit breaker transitioning to CLOSED'); + this.circuitBreaker.state = 'CLOSED'; + this.circuitBreaker.failureCount = 0; + } + } else if (this.circuitBreaker.state === 'CLOSED') { + this.circuitBreaker.failureCount = 0; + } + }, + + /** + * Record failed request + * @private + */ + _recordFailure: function() { + this.circuitBreaker.failureCount++; + this.circuitBreaker.lastFailureTime = new Date().getTime(); + + if (this.circuitBreaker.failureCount >= this.circuitBreaker.failureThreshold) { + gs.warn('[RESTIntegrationHandler] Circuit breaker transitioning to OPEN'); + this.circuitBreaker.state = 'OPEN'; + } + }, + + /** + * Check rate limit using token bucket algorithm + * @private + */ + _checkRateLimit: function() { + var now = new Date().getTime(); + var timePassed = (now - this.rateLimiter.lastRefill) / 1000; // seconds + + // Refill tokens + this.rateLimiter.tokens = Math.min( + this.rateLimit, + this.rateLimiter.tokens + (timePassed * this.rateLimiter.refillRate) + ); + this.rateLimiter.lastRefill = now; + + // Check if we have tokens available + if (this.rateLimiter.tokens >= 1) { + this.rateLimiter.tokens -= 1; + return true; + } + + gs.warn('[RESTIntegrationHandler] Rate limit exceeded'); + return false; + }, + + /** + * Check if HTTP status code is retryable + * @private + */ + _isRetryable: function(statusCode) { + // Retry on server errors (5xx) and specific client errors + var retryableCodes = [408, 429, 500, 502, 503, 504]; + return retryableCodes.indexOf(statusCode) !== -1; + }, + + /** + * Add authentication to request + * @private + */ + _addAuthentication: function(request, options) { + var authType = gs.getProperty('x_company.' + this.integrationName + '.auth_type', 'bearer'); + + if (authType === 'bearer') { + var token = this._getAuthToken(); + if (token) { + request.setRequestHeader('Authorization', 'Bearer ' + token); + } + } else if (authType === 'basic') { + var username = gs.getProperty('x_company.' + this.integrationName + '.username'); + var password = gs.getProperty('x_company.' + this.integrationName + '.password'); + request.setBasicAuth(username, password); + } else if (authType === 'apikey') { + var apiKey = gs.getProperty('x_company.' + this.integrationName + '.api_key'); + var headerName = gs.getProperty('x_company.' + this.integrationName + '.api_key_header', 'X-API-Key'); + request.setRequestHeader(headerName, apiKey); + } + }, + + /** + * Get authentication token (with caching) + * @private + */ + _getAuthToken: function() { + // Check cache + var cacheKey = 'auth_token_' + this.integrationName; + var cachedToken = this._getFromCache(cacheKey); + if (cachedToken) { + return cachedToken.token; + } + + // Acquire new token (implement OAuth 2.0 flow here) + var token = gs.getProperty('x_company.' + this.integrationName + '.access_token'); + + // Cache token + this._addToCache(cacheKey, { token: token }, 3600000); // 1 hour + + return token; + }, + + /** + * Cache management + * @private + */ + _getCacheKey: function(endpoint, params) { + return endpoint + '_' + JSON.stringify(params || {}); + }, + + _getFromCache: function(key) { + var cached = this.cache[key]; + if (cached && new Date().getTime() - cached.timestamp < this.cacheTimeout) { + return cached.data; + } + return null; + }, + + _addToCache: function(key, data, ttl) { + this.cache[key] = { + data: data, + timestamp: new Date().getTime(), + ttl: ttl || this.cacheTimeout + }; + }, + + /** + * Parse response headers + * @private + */ + _parseHeaders: function(response) { + var headers = {}; + var headerKeys = response.getHeaders(); + for (var i = 0; i < headerKeys.size(); i++) { + var key = headerKeys.get(i); + headers[key] = response.getHeader(key); + } + return headers; + }, + + /** + * Extract error message from response + * @private + */ + _extractErrorMessage: function(data, statusCode) { + if (typeof data === 'object') { + return data.error || data.message || data.error_description || 'HTTP ' + statusCode; + } + return 'HTTP ' + statusCode + ': ' + data; + }, + + /** + * Log request/response for debugging + * @private + */ + _logRequest: function(method, url, body, statusCode, responseBody) { + if (gs.getProperty('x_company.' + this.integrationName + '.debug', 'false') === 'true') { + gs.info('[RESTIntegrationHandler] ' + method + ' ' + url); + if (body) { + gs.debug('[RESTIntegrationHandler] Request: ' + JSON.stringify(body)); + } + gs.info('[RESTIntegrationHandler] Response: ' + statusCode); + gs.debug('[RESTIntegrationHandler] Body: ' + responseBody); + } + }, + + type: 'RESTIntegrationHandler' +};