Skip to main content
Glama
save-composition-api.js26.2 kB
#!/usr/bin/env node /** * Composition API Saver Tool v5.2.0 - FULLY OPERATIONAL * Enhanced API save with payload optimization and comprehensive error handling * @version 5.2.0 (January 12, 2025) * @status FULLY OPERATIONAL - Robust API integration with retry logic * @reference JIT workflow step 6 of 7 * @milestone v5.2.0 - Production-ready API save with detailed response handling */ import { createPayloadOptimizer } from './payload-optimizer.js'; export class CompositionAPISaver { constructor() { this.processingStartTime = null; this.apiLog = []; this.authenticationLog = []; this.payloadOptimizer = createPayloadOptimizer(); this.maxRetries = 3; } /** * Main API saving entry point with retry logic and payload optimization */ async saveCompositionAPI(composerJSON, page) { this.processingStartTime = Date.now(); this.apiLog = []; this.authenticationLog = []; console.error('[SAVE_COMPOSITION_API] Starting API save with enhanced debugging and optimization'); // Step 0: Optimize payload size if needed const optimizationResult = this.payloadOptimizer.optimizeComposition(composerJSON); let currentComposition = optimizationResult.composition; if (optimizationResult.optimized) { console.error(`[SAVE_COMPOSITION_API] Payload optimized: ${optimizationResult.reductionPercent}% reduction`); this.logAPI('PAYLOAD_OPTIMIZATION', 'Composition optimized for API limits', true, { originalSize: optimizationResult.originalSize, finalSize: optimizationResult.finalSize, optimizations: optimizationResult.optimizations }); } // Retry logic with progressive optimization for (let attempt = 1; attempt <= this.maxRetries; attempt++) { console.error(`[SAVE_COMPOSITION_API] Attempt ${attempt}/${this.maxRetries}`); try { const result = await this.attemptSave(currentComposition, page, attempt); if (result.success) { // Success! Include optimization info in response result.optimizationApplied = optimizationResult.optimized; result.optimizationDetails = optimizationResult.optimized ? { sizeSavings: optimizationResult.originalSize - optimizationResult.finalSize, reductionPercent: optimizationResult.reductionPercent, optimizations: optimizationResult.optimizations } : null; return result; } // If we got a 500 error and have retries left, try further optimization if (result.isServerError && attempt < this.maxRetries) { console.error(`[SAVE_COMPOSITION_API] Server error on attempt ${attempt}, further optimizing...`); // Apply more aggressive optimization for next attempt currentComposition = this.applyAggressiveOptimization(currentComposition, attempt); this.logAPI('RETRY_OPTIMIZATION', `Further optimization for attempt ${attempt + 1}`, true, { attempt: attempt, aggressiveOptimization: true }); continue; } // If not a server error or last attempt, return the error if (!result.isServerError || attempt === this.maxRetries) { return result; } } catch (error) { console.error(`[SAVE_COMPOSITION_API] Attempt ${attempt} failed:`, error.message); if (attempt === this.maxRetries) { return this.createSystemErrorResponse(error); } } } // Should not reach here, but just in case return this.createSystemErrorResponse(new Error('All retry attempts exhausted')); } /** * Single save attempt */ async attemptSave(composerJSON, page, attemptNumber) { try { // Step 1: Extract and validate authentication data const authData = await this.extractAuthenticationData(page); this.logAuthentication('AUTH_EXTRACTION', 'Authentication data extracted successfully', true, { projectUid: authData.projectUid, tokenPreview: authData.accessToken?.substring(0, 50) + '...', connectorsCount: authData.connectors.length }); // Step 2: Prepare API request with intelligent connector selection const requestData = await this.prepareAPIRequest(composerJSON, authData); this.logAPI('REQUEST_PREPARATION', 'API request prepared successfully', true, { fileName: requestData.fileName, fileSize: requestData.fileSize, connectorUid: requestData.connector.uid, apiEndpoint: requestData.apiEndpoint, attemptNumber: attemptNumber }); // Step 3: Execute API call with comprehensive error handling const apiResponse = await this.executeAPICall(requestData, page); if (!apiResponse.success) { const errorResponse = this.createErrorResponse(apiResponse, requestData); errorResponse.isServerError = this.isServerError(apiResponse); return errorResponse; } // Step 4: Process successful response and extract composition UID const processedResponse = this.processAPIResponse(apiResponse); if (!processedResponse.success) { return this.createErrorResponse(processedResponse, requestData); } const processingTime = Date.now() - this.processingStartTime; console.error(`[SAVE_COMPOSITION_API] ✅ API save successful: ${processedResponse.compositionUid}`); return { success: true, data: { compositionUid: processedResponse.compositionUid, apiResponse: apiResponse.data, uploadDetails: { fileName: requestData.fileName, fileSize: requestData.fileSize, connectorUsed: requestData.connector.name || requestData.connector.uid, projectUid: authData.projectUid, apiEndpoint: requestData.apiEndpoint }, authenticationUsed: { tokenType: authData.tokenType, tokenPreview: authData.accessToken?.substring(0, 50) + '...', projectUid: authData.projectUid, connectorUid: requestData.connector.uid, extractionSuccess: true } }, debug: { timestamp: new Date().toISOString(), processingTime: processingTime, apiLog: this.apiLog, authenticationLog: this.authenticationLog } }; } catch (error) { console.error('[SAVE_COMPOSITION_API] ❌ API save error:', error.message); return { success: false, error: { code: 'API_SAVE_ERROR', message: error.message, details: { httpStatus: null, responseText: error.message, requestDetails: null, possibleCauses: ['System error during API save process'], suggestedFixes: ['Check system logs', 'Retry the operation', 'Verify network connectivity'] } }, debug: { timestamp: new Date().toISOString(), processingTime: Date.now() - this.processingStartTime, apiLog: this.apiLog, authenticationLog: this.authenticationLog } }; } } /** * Extract authentication data from browser localStorage */ async extractAuthenticationData(page) { this.logAuthentication('AUTH_EXTRACTION_START', 'Starting authentication data extraction'); return await page.evaluate(() => { console.error('=== AUTHENTICATION EXTRACTION v1.0.0 ==='); const activeProject = localStorage.getItem('rdp-composer-active-project'); const userData = localStorage.getItem('rdp-composer-user-data'); console.error('Raw activeProject present:', !!activeProject); console.error('Raw userData present:', !!userData); if (!activeProject) { throw new Error('Active project data not found in localStorage'); } if (!userData) { throw new Error('User data not found in localStorage'); } let projectData, userDataParsed; try { projectData = JSON.parse(activeProject); } catch (e) { throw new Error('Failed to parse active project data: ' + e.message); } try { userDataParsed = JSON.parse(userData); } catch (e) { throw new Error('Failed to parse user data: ' + e.message); } if (!projectData.uid) { throw new Error('Project UID not found in active project data'); } if (!userDataParsed.access_token) { throw new Error('Access token not found in user data'); } if (!projectData.connectors || !Array.isArray(projectData.connectors)) { throw new Error('Connectors array not found or invalid in project data'); } if (projectData.connectors.length === 0) { throw new Error('No connectors available in project'); } const result = { projectUid: projectData.uid, connectors: projectData.connectors, accessToken: userDataParsed.access_token, tokenType: userDataParsed.token_type || 'Bearer' }; console.error('Authentication extraction successful:', { projectUid: result.projectUid, connectorsCount: result.connectors.length, hasAccessToken: !!result.accessToken, tokenType: result.tokenType, tokenLength: result.accessToken.length }); return result; }); } /** * Prepare API request with intelligent connector selection */ async prepareAPIRequest(composition, authData) { this.logAPI('REQUEST_PREP_START', 'Starting API request preparation'); // Generate unique filename const timestamp = Date.now(); const safeName = (composition.metadata.title || 'lesson').replace(/[^a-zA-Z0-9]/g, '_'); const fileName = `composition_${timestamp}_${safeName}.rdpcomposer`; // Select optimal connector const connector = this.selectOptimalConnector(authData.connectors); this.logAPI('CONNECTOR_SELECTION', `Selected connector: ${connector.name || connector.uid}`, true, { connectorName: connector.name, connectorUid: connector.uid, totalConnectors: authData.connectors.length }); // Build API endpoint const apiEndpoint = `https://api.digitalpages.com.br/storage/v1.0/upload/connector/uid/${connector.uid}?manual_project_uid=${authData.projectUid}`; // Prepare headers const headers = { 'Authorization': `${authData.tokenType} ${authData.accessToken}`, 'Project-Key': 'e3894d14dbb743d78a7efc5819edc52e', 'Api-Env': 'prd' }; // Prepare composition data (blob creation will happen in browser context) const compositionData = JSON.stringify(composition, null, 2); return { compositionData, headers, apiEndpoint, fileName, fileSize: compositionData.length, connector }; } /** * Select optimal connector with priority logic */ selectOptimalConnector(connectors) { this.logAPI('CONNECTOR_SELECTION_START', `Analyzing ${connectors.length} available connectors`); // Priority 1: ContentManager connector (recommended by tech team) const contentManager = connectors.find(c => c.name && c.name.toLowerCase().includes('contentmanager') ); if (contentManager) { this.logAPI('CONNECTOR_PRIORITY_1', 'Found ContentManager connector', true); return contentManager; } // Priority 2: Composer-specific connector const composer = connectors.find(c => c.name && c.name.toLowerCase().includes('composer') ); if (composer) { this.logAPI('CONNECTOR_PRIORITY_2', 'Found Composer connector', true); return composer; } // Priority 3: First connector with upload permissions const uploadCapable = connectors.find(c => c.permissions && c.permissions.includes && c.permissions.includes('upload') ); if (uploadCapable) { this.logAPI('CONNECTOR_PRIORITY_3', 'Found upload-capable connector', true); return uploadCapable; } // Priority 4: Any available connector if (connectors.length > 0) { this.logAPI('CONNECTOR_FALLBACK', 'Using first available connector', true); return connectors[0]; } throw new Error('No suitable connector found for composition upload'); } /** * Execute API call with comprehensive error handling */ async executeAPICall(requestData, page) { this.logAPI('API_CALL_START', 'Executing API call', true, { endpoint: requestData.apiEndpoint, fileName: requestData.fileName, fileSize: requestData.fileSize }); return await page.evaluate(async (data) => { console.error('=== API CALL EXECUTION v1.0.0 ==='); console.error('Endpoint:', data.apiEndpoint); console.error('File size:', data.fileSize); console.error('Headers:', Object.keys(data.headers)); try { // Create blob and FormData in browser context const compositionBlob = new Blob([data.compositionData], { type: 'application/json' }); const formData = new FormData(); formData.append('file', compositionBlob, data.fileName); const response = await fetch(data.apiEndpoint, { method: 'POST', headers: data.headers, body: formData }); console.error('Response status:', response.status); console.error('Response status text:', response.statusText); console.error('Response headers:', Object.fromEntries(response.headers.entries())); const responseText = await response.text(); console.error('Response text (first 500 chars):', responseText.substring(0, 500)); if (!response.ok) { return { success: false, status: response.status, statusText: response.statusText, responseText: responseText, headers: Object.fromEntries(response.headers.entries()), error: `HTTP ${response.status}: ${response.statusText}` }; } // Parse JSON response let parsedResponse; try { parsedResponse = JSON.parse(responseText); } catch (parseError) { return { success: false, status: response.status, error: 'Invalid JSON response from server', responseText: responseText, parseError: parseError.message }; } console.error('✅ API call successful'); console.error('Parsed response structure:', Object.keys(parsedResponse)); return { success: true, status: response.status, data: parsedResponse, responseText: responseText, headers: Object.fromEntries(response.headers.entries()) }; } catch (fetchError) { console.error('❌ Fetch error:', fetchError); return { success: false, error: fetchError.message, stack: fetchError.stack, networkError: true }; } }, requestData); } /** * Process API response and extract composition UID */ processAPIResponse(apiResponse) { this.logAPI('RESPONSE_PROCESSING', 'Processing API response', true, { status: apiResponse.status, hasData: !!apiResponse.data }); if (!apiResponse.success) { const errorAnalysis = this.analyzeAPIError(apiResponse); return { success: false, error: errorAnalysis }; } // Extract composition UID from successful response const compositionUid = this.extractCompositionUID(apiResponse.data); if (!compositionUid) { return { success: false, error: { code: 'UID_EXTRACTION_ERROR', message: 'Could not extract composition UID from API response', details: { responseStructure: Object.keys(apiResponse.data || {}), fullResponse: apiResponse.data, possibleCauses: [ 'API response format changed', 'Unexpected response structure', 'Missing UID field in response' ], suggestedFixes: [ 'Check API documentation for response format', 'Verify composition was actually saved', 'Contact support with response details' ] } } }; } this.logAPI('UID_EXTRACTION', `Successfully extracted composition UID: ${compositionUid}`, true); return { success: true, compositionUid: compositionUid }; } /** * Extract composition UID with multiple format support */ extractCompositionUID(responseData) { if (!responseData) return null; // Array response format if (Array.isArray(responseData) && responseData[0]) { const firstItem = responseData[0]; return firstItem.uid || firstItem.id || firstItem.compositionId || null; } // Direct object response if (typeof responseData === 'object') { return responseData.uid || responseData.id || responseData.compositionId || responseData.composition_uid || responseData.fileId || null; } // String response (direct UID) if (typeof responseData === 'string' && responseData.length > 10) { return responseData; } return null; } /** * Analyze API errors with detailed diagnostics */ analyzeAPIError(apiResponse) { const status = apiResponse.status; const analysis = { code: `API_ERROR_${status || 'UNKNOWN'}`, message: apiResponse.error || 'Unknown API error', details: { httpStatus: status, responseText: apiResponse.responseText || 'No response text', requestDetails: null, possibleCauses: this.getPossibleCauses(status), suggestedFixes: this.getSuggestedFixes(status) } }; // Add network error context if (apiResponse.networkError) { analysis.details.possibleCauses.unshift('Network connectivity issues'); analysis.details.suggestedFixes.unshift('Check network connection'); } return analysis; } /** * Get possible causes based on HTTP status */ getPossibleCauses(status) { switch (status) { case 400: return [ 'Invalid composition JSON structure', 'Missing required form data fields', 'Malformed request parameters', 'Invalid file format' ]; case 401: return [ 'Access token expired or invalid', 'Missing Authorization header', 'Token format incorrect', 'Authentication session expired' ]; case 403: return [ 'Insufficient permissions for selected connector', 'Project access denied', 'API key restrictions', 'User account permissions insufficient' ]; case 404: return [ 'Connector UID not found', 'Project UID not found', 'API endpoint incorrect', 'Resource does not exist' ]; case 413: return [ 'Composition file too large', 'Request payload exceeds server limits' ]; case 429: return [ 'Rate limit exceeded', 'Too many requests in short period' ]; case 500: return [ 'Composer server internal error', 'Database operation failed', 'File processing error on server', 'Composition data structure incompatible', 'Server configuration issue' ]; case 502: case 503: case 504: return [ 'Composer service temporarily unavailable', 'Server overloaded', 'Gateway timeout' ]; default: return ['Unknown error - check response details']; } } /** * Get suggested fixes based on HTTP status */ getSuggestedFixes(status) { switch (status) { case 400: return [ 'Validate composition JSON structure', 'Check for malformed widget content', 'Verify all required fields are present', 'Review quiz questions format' ]; case 401: return [ 'Re-authenticate by refreshing the browser page', 'Check if JWT token file is current', 'Verify localStorage contains valid user data', 'Re-login to EuConquisto Composer' ]; case 403: return [ 'Try a different connector with upload permissions', 'Verify project access permissions', 'Contact administrator for permission review', 'Check user account status' ]; case 404: return [ 'Verify connector UID is correct', 'Check project UID in localStorage', 'Confirm API endpoint URL', 'Refresh project data' ]; case 413: return [ 'Reduce composition size by removing large assets', 'Optimize widget content', 'Split into smaller compositions' ]; case 429: return [ 'Wait before retrying request', 'Implement exponential backoff', 'Contact support for rate limit increase' ]; case 500: return [ 'Retry the request after a short delay', 'Validate composition JSON structure', 'Check for invalid URLs in assets', 'Review quiz questions format (common cause)', 'Contact support with composition data' ]; case 502: case 503: case 504: return [ 'Retry after waiting', 'Check Composer service status', 'Wait for service restoration', 'Contact support if issue persists' ]; default: return ['Check API documentation for status code details']; } } /** * Create standardized error response */ createErrorResponse(errorData, requestData = null) { const processingTime = Date.now() - this.processingStartTime; return { success: false, error: errorData.error || errorData, debug: { timestamp: new Date().toISOString(), processingTime: processingTime, apiLog: this.apiLog, authenticationLog: this.authenticationLog } }; } /** * Logging utilities */ logAPI(action, details, success = true, data = null) { this.apiLog.push({ timestamp: new Date().toISOString(), action: action, details: details, success: success, data: data }); } logAuthentication(step, details, success = true, data = null) { this.authenticationLog.push({ timestamp: new Date().toISOString(), step: step, details: details, success: success, data: data }); } /** * Apply aggressive optimization for retry attempts */ applyAggressiveOptimization(composition, attemptNumber) { console.error(`[SAVE_COMPOSITION_API] Applying aggressive optimization for attempt ${attemptNumber + 1}`); let optimized = JSON.parse(JSON.stringify(composition)); // Attempt 2: More aggressive text compression if (attemptNumber === 1) { optimized.structure = optimized.structure.map(widget => { if (widget.type === 'text-1' && widget.text && widget.text.length > 1000) { const truncated = this.aggressiveTextTruncation(widget.text, 1000); console.error(`[SAVE_COMPOSITION_API] Aggressively truncated text widget to 1000 chars`); return { ...widget, text: truncated }; } return widget; }); } // Attempt 3: Remove least essential widgets if (attemptNumber === 2) { const essentialTypes = ['head-1', 'text-1', 'quiz-1']; const filtered = optimized.structure.filter(widget => essentialTypes.includes(widget.type) ); // Keep max 8 widgets optimized.structure = filtered.slice(0, 8); console.error(`[SAVE_COMPOSITION_API] Reduced to ${optimized.structure.length} essential widgets`); } return optimized; } /** * Aggressive text truncation for retry attempts */ aggressiveTextTruncation(text, maxLength) { if (text.length <= maxLength) return text; // Find the last complete sentence within the limit const truncated = text.substring(0, maxLength); const lastSentence = truncated.lastIndexOf('.'); if (lastSentence > maxLength * 0.6) { return text.substring(0, lastSentence + 1) + '</p>'; } else { // Emergency truncation with proper HTML closing const lastTag = truncated.lastIndexOf('<'); const safePoint = lastTag > 0 ? lastTag : maxLength - 10; return text.substring(0, safePoint) + '</p>'; } } /** * Check if the error is a server error (500 series) */ isServerError(apiResponse) { const status = apiResponse.status; return status >= 500 && status < 600; } /** * Create system error response for retry failures */ createSystemErrorResponse(error) { const processingTime = Date.now() - this.processingStartTime; return { success: false, error: error.message, debug: { timestamp: new Date().toISOString(), processingTime: processingTime, apiLog: this.apiLog, authenticationLog: this.authenticationLog, retryExhausted: true } }; } } /** * Create and export the API saver component for JIT server integration */ export function createCompositionAPISaver() { return new CompositionAPISaver(); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/rkm097git/euconquisto-composer-mcp-poc'

If you have feedback or need assistance with the MCP directory API, please join our Discord server