/** * Microsoft Graph API helper functions */ const https = require('https'); const config = require('../config'); const mockData = require('./mock-data'); /** * Sleep helper for backoff delays. * @param {number} ms - Milliseconds to wait * @returns {Promise} */ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Compute the next retry delay. * Honors a Retry-After header (seconds) when present, otherwise uses * exponential backoff with full jitter. Clamps to MAX_RETRY_DELAY_MS. * @param {number} attempt - 0-based attempt index * @param {number|undefined} retryAfterSeconds - Retry-After header value * @returns {number} - Delay in milliseconds */ function getRetryDelay(attempt, retryAfterSeconds) { if (retryAfterSeconds && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) { return Math.min(retryAfterSeconds * 1000, config.MAX_RETRY_DELAY_MS); } const exponential = config.BASE_RETRY_DELAY_MS * Math.pow(2, attempt); const jitter = Math.random() * exponential; return Math.min(exponential + jitter, config.MAX_RETRY_DELAY_MS); } /** * Makes a request to the Microsoft Graph API * @param {string} accessToken - The access token for authentication * @param {string} method - HTTP method (GET, POST, etc.) * @param {string} path - API endpoint path * @param {object} data - Data to send for POST/PUT requests * @param {object} queryParams - Query parameters * @returns {Promise} - The API response */ async function callGraphAPI(accessToken, method, path, data = null, queryParams = {}) { // For test tokens, we'll simulate the API call if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) { console.error(`TEST MODE: Simulating ${method} ${path} API call`); return mockData.simulateGraphAPIResponse(method, path, data, queryParams); } try { console.error(`Making real API call: ${method} ${path}`); // Encode path segments properly const encodedPath = path.split('/') .map(segment => encodeURIComponent(segment)) .join('/'); // Build query string from parameters with special handling for OData filters let queryString = ''; if (Object.keys(queryParams).length > 0) { // Handle $filter parameter specially to ensure proper URI encoding const filter = queryParams.$filter; if (filter) { delete queryParams.$filter; // Remove from regular params } // Build query string with proper encoding for regular params const params = new URLSearchParams(); for (const [key, value] of Object.entries(queryParams)) { params.append(key, value); } queryString = params.toString(); // Add filter parameter separately with proper encoding if (filter) { if (queryString) { queryString += `&$filter=${encodeURIComponent(filter)}`; } else { queryString = `$filter=${encodeURIComponent(filter)}`; } } if (queryString) { queryString = '?' + queryString; } console.error(`Query string: ${queryString}`); } const url = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`; console.error(`Full URL: ${url}`); const maxAttempts = Math.max(1, config.MAX_RETRIES + 1); for (let attempt = 0; attempt < maxAttempts; attempt++) { const result = await makeSingleRequest(url, optionsForRequest(method, accessToken), data); if (result.success) { return result.body; } // Do not retry authentication failures. if (result.status === 401) { throw new Error('UNAUTHORIZED'); } // Retry throttling / transient server errors. const isRetryable = result.status === 429 || result.status === 503; if (!isRetryable || attempt === maxAttempts - 1) { throw new Error(`API call failed with status ${result.status}: ${result.body}`); } const retryAfterSeconds = result.retryAfter ? parseInt(result.retryAfter, 10) : undefined; const delayMs = getRetryDelay(attempt, retryAfterSeconds); console.error(`Retry ${attempt + 1}/${config.MAX_RETRIES} after ${delayMs}ms (status ${result.status})`); await sleep(delayMs); } // Should not reach here; final failure is thrown inside the loop. throw new Error(`API call failed after ${maxAttempts} attempts`); } catch (error) { console.error('Error calling Graph API:', error); throw error; } } /** * Build the https request options object. */ function optionsForRequest(method, accessToken) { return { method: method, headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }; } /** * Execute one HTTPS request and return a normalized result. * @returns {Promise<{success: boolean, status: number, body: any, retryAfter?: string}>} */ function makeSingleRequest(url, options, data) { return new Promise((resolve, reject) => { const req = https.request(url, options, (res) => { let responseData = ''; res.on('data', (chunk) => { responseData += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { const trimmed = responseData.trim(); if (!trimmed) { return resolve({ success: true, status: res.statusCode, body: {} }); } try { const jsonResponse = JSON.parse(trimmed); resolve({ success: true, status: res.statusCode, body: jsonResponse }); } catch (error) { reject(new Error(`Error parsing API response: ${error.message}`)); } } else { resolve({ success: false, status: res.statusCode, body: responseData, retryAfter: res.headers['retry-after'] }); } }); }); req.on('error', (error) => { reject(new Error(`Network error during API call: ${error.message}`)); }); if (data && (options.method === 'POST' || options.method === 'PATCH' || options.method === 'PUT')) { req.write(JSON.stringify(data)); } req.end(); }); } module.exports = { callGraphAPI, getRetryDelay, sleep };