save-composition-api.js•26.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();
}