Skip to main content
Glama
dxp-rest-client.ts28.1 kB
/** * DXP REST API Client * Direct REST API calls to Optimizely DXP without PowerShell dependency * Implements HMAC-SHA256 authentication as used by EpiCloud PowerShell module * Part of Jaxon Digital Optimizely DXP MCP Server */ import * as crypto from 'crypto'; import * as https from 'https'; import { URL } from 'url'; import OutputLogger from './output-logger'; import RateLimiter from './rate-limiter'; interface RequestOptions { apiUrl?: string; projectId?: string; timeout?: number; maxRetries?: number; retryDelay?: number; } interface DeploymentParams { TargetEnvironment: string; SourceEnvironment?: string; PackageUrl?: string; ZeroDowntimeMode?: string; DirectDeploy?: boolean; IncludeBlob?: boolean; IncludeDb?: boolean; SourceApp?: string; } interface ResetOptions { RollbackDatabase?: boolean; ValidateBeforeSwap?: boolean; Complete?: boolean; } interface SasOptions { retentionHours?: number; RetentionHours?: number; writable?: boolean; Writable?: boolean; } interface APIError extends Error { statusCode?: number; response?: string; retryAfter?: string | number; code?: string; } interface RateCheckResult { allowed: boolean; reason?: string; waitTime?: number; } class DXPRestClient { /** * Default API endpoint */ static DEFAULT_ENDPOINT = 'https://paasportal.episerver.net/api/v1.0/'; /** * Shared rate limiter instance for all API calls * Tracks rate limits per project ID */ static rateLimiter = new RateLimiter({ maxRequestsPerMinute: 30, // Conservative limit for Optimizely API maxRequestsPerHour: 500, // Hourly limit burstAllowance: 5, // Allow small bursts debug: process.env.DEBUG === 'true' }); /** * Generate HMAC-SHA256 signature for API request * Based on SetApiAuthorizationHeader from EpiCloud.psm1 (lines 533-608) * * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret (base64 encoded) * @param {string} method - HTTP method (GET, POST, etc.) * @param {string} pathAndQuery - URL path and query string * @param {string} body - Request body (JSON string) * @returns {string} Authorization header value */ static generateAuthHeader( clientKey: string, clientSecret: string, method: string, pathAndQuery: string, body: string = '' ): string { try { // Generate timestamp (Unix milliseconds) const timestamp = Date.now().toString(); // Generate nonce (UUID without hyphens) const nonce = crypto.randomUUID().replace(/-/g, ''); // Calculate MD5 hash of body const bodyBytes = Buffer.from(body, 'utf8'); const bodyHash = crypto.createHash('md5').update(bodyBytes).digest('base64'); // Combine message components (line 596 in EpiCloud.psm1) // Format: {ClientKey}{Method}{Path}{Timestamp}{Nonce}{BodyHash} const message = `${clientKey}${method.toUpperCase()}${pathAndQuery}${timestamp}${nonce}${bodyHash}`; // Create HMAC-SHA256 signature const secretBuffer = Buffer.from(clientSecret, 'base64'); const signature = crypto .createHmac('sha256', secretBuffer) .update(message, 'utf8') .digest('base64'); // Format authorization header (line 604 in EpiCloud.psm1) // Format: "epi-hmac {ClientKey}:{Timestamp}:{Nonce}:{Signature}" return `epi-hmac ${clientKey}:${timestamp}:${nonce}:${signature}`; } catch (error) { throw new Error(`Failed to generate auth header: ${(error as Error).message}`); } } /** * Make authenticated API request with rate limiting and automatic retry * Based on InvokeApiRequest from EpiCloud.psm1 (lines 369-440) * * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {string} uriEnding - API endpoint path * @param {string} method - HTTP method (default: GET) * @param {Object} payload - Request payload (optional) * @param {Object|string} options - Options object with apiUrl, projectId, or legacy baseUrl string * @returns {Promise<Object>} API response */ static async makeRequest( clientKey: string, clientSecret: string, uriEnding: string, method: string = 'GET', payload: any = null, options: RequestOptions | string | null = null ): Promise<any> { // Extract project ID for rate limiting (from URI or options) let projectId = 'unknown'; if (options && typeof options === 'object' && options.projectId) { projectId = options.projectId; } else { // Try to extract from URI (format: projects/{projectId}/...) const match = uriEnding.match(/^projects\/([^\/]+)/); if (match) { projectId = match[1]; } } // Configure retry parameters const maxRetries = (options && typeof options === 'object' && options.maxRetries) || 3; const retryDelay = (options && typeof options === 'object' && options.retryDelay) || 1000; let lastError: APIError | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // Check rate limit BEFORE making request const rateCheck: RateCheckResult = this.rateLimiter.checkRateLimit(projectId, 'api_call'); if (!rateCheck.allowed) { const waitMs = rateCheck.waitTime || 1000; OutputLogger.debug(`Rate limit: waiting ${waitMs}ms (${rateCheck.reason}) before retry ${attempt}/${maxRetries}`); // Wait for rate limit to clear await this._sleep(waitMs); // Try again after waiting continue; } // Handle legacy baseUrl parameter (for backward compatibility) let apiUrl: string | null = null; if (typeof options === 'string') { // Legacy: options was baseUrl string apiUrl = options; } else if (options && typeof options === 'object') { // New: options object with apiUrl apiUrl = options.apiUrl || null; } // Priority order for API endpoint: // 1. Directly passed apiUrl (via options) // 2. Global environment variable OPTIMIZELY_API_URL // 3. DEFAULT_ENDPOINT const endpoint = apiUrl || process.env.OPTIMIZELY_API_URL || this.DEFAULT_ENDPOINT; const fullUrl = new URL(uriEnding, endpoint); // Prepare request body let body = ''; if (payload) { body = JSON.stringify(payload); } // Generate authentication header const pathAndQuery = fullUrl.pathname + fullUrl.search; const authHeader = this.generateAuthHeader(clientKey, clientSecret, method, pathAndQuery, body); // Extract timeout from options (default: 120s for most operations, can be overridden) const timeout = (options && typeof options === 'object' && options.timeout) || 120000; // Prepare request options const requestOptions: https.RequestOptions = { hostname: fullUrl.hostname, port: fullUrl.port || 443, path: pathAndQuery, method: method, headers: { 'Authorization': authHeader, 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'Jaxon-Optimizely-DXP-MCP/3.0' }, timeout: timeout // Configurable timeout (default: 120s) }; if (body) { (requestOptions.headers as any)['Content-Length'] = Buffer.byteLength(body).toString(); } OutputLogger.debug(`API Request: ${method} ${fullUrl.href} (attempt ${attempt}/${maxRetries})`); // Make the request const result = await this._executeRequest(requestOptions, body); // Success - record the request and return this.rateLimiter.recordRequest(projectId, 'api_call'); return result; } catch (error) { lastError = error as APIError; // Check if it's a 429 rate limit error if (lastError.statusCode === 429) { // Parse Retry-After header from response const retryAfter = this._parseRetryAfter(lastError.retryAfter) || retryDelay * attempt; // Record the rate limit in our tracker this.rateLimiter.recordRateLimit(projectId, { retryAfter }); // If we have more attempts, wait and retry if (attempt < maxRetries) { const waitMs = Math.min(retryAfter, 30000); // Cap at 30 seconds OutputLogger.debug(`HTTP 429: Rate limit exceeded, waiting ${Math.round(waitMs/1000)}s before retry ${attempt + 1}/${maxRetries}`); await this._sleep(waitMs); continue; } // No more retries - throw with helpful message throw new Error(`Rate limit exceeded after ${maxRetries} attempts. Please retry after ${Math.round(retryAfter/1000)} seconds.`); } // Check for other retryable errors (503, 502, network errors) if (this._isRetryableError(lastError) && attempt < maxRetries) { const waitMs = retryDelay * Math.pow(2, attempt - 1); // Exponential backoff OutputLogger.debug(`Retryable error (${lastError.message}), waiting ${waitMs}ms before retry ${attempt + 1}/${maxRetries}`); await this._sleep(waitMs); continue; } // Not retryable or out of retries - throw the error throw new Error(`API request failed: ${lastError.message}`); } } // Should never reach here, but just in case throw new Error(`API request failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`); } /** * Execute HTTP request and parse response * * @private * @param {Object} options - Request options * @param {string} body - Request body * @returns {Promise<Object>} Parsed response */ static _executeRequest(options: https.RequestOptions, body: string): Promise<any> { return new Promise((resolve, reject) => { const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { // Check HTTP status if (res.statusCode! < 200 || res.statusCode! >= 300) { // Try to parse error response let errorMessage = `HTTP ${res.statusCode}`; try { const errorData = JSON.parse(data); if (errorData.errors) { errorMessage += `: ${errorData.errors.join(', ')}`; } else if (errorData.message) { errorMessage += `: ${errorData.message}`; } } catch { errorMessage += `: ${data.substring(0, 200)}`; } const error = new Error(errorMessage) as APIError; error.statusCode = res.statusCode; error.response = data; // Capture Retry-After header for rate limiting error.retryAfter = res.headers['retry-after']; reject(error); return; } // Parse successful response if (data) { const parsed = JSON.parse(data); // Check API success flag (from EpiCloud.psm1 line 430-432) if (parsed.success === false) { const errorMsg = parsed.errors ? parsed.errors.join(', ') : 'Unknown error'; reject(new Error(`API call failed: ${errorMsg}`)); return; } // Return result if present, otherwise full response resolve(parsed.result || parsed); } else { resolve({}); } } catch (error) { reject(new Error(`Failed to parse response: ${(error as Error).message}`)); } }); }); req.on('error', (error) => { reject(new Error(`Request error: ${error.message}`)); }); req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); }); if (body) { req.write(body); } req.end(); }); } /** * Check if error is retryable (network/temporary errors) * @private * @param {Error} error - Error object * @returns {boolean} True if retryable */ static _isRetryableError(error: APIError): boolean { if (!error) return false; // Check status codes if (error.statusCode === 502 || // Bad Gateway error.statusCode === 503 || // Service Unavailable error.statusCode === 504) { // Gateway Timeout return true; } // Check error codes const retryableCodes = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENETUNREACH']; if (error.code && retryableCodes.includes(error.code)) { return true; } // Check message patterns const message = (error.message || '').toLowerCase(); if (message.includes('timeout') || message.includes('network') || message.includes('connection')) { return true; } return false; } /** * Parse Retry-After header * @private * @param {string|number} retryAfter - Retry-After header value * @returns {number} Milliseconds to wait */ static _parseRetryAfter(retryAfter: string | number | undefined): number | null { if (!retryAfter) return null; // If it's a number, treat as seconds if (typeof retryAfter === 'number') { return retryAfter * 1000; } // If it's a string, could be seconds or HTTP date if (typeof retryAfter === 'string') { const seconds = parseInt(retryAfter, 10); if (!isNaN(seconds)) { return seconds * 1000; } // Try parsing as date const date = new Date(retryAfter); if (!isNaN(date.getTime())) { return Math.max(0, date.getTime() - Date.now()); } } return null; } /** * Sleep helper * @private * @param {number} ms - Milliseconds to sleep * @returns {Promise<void>} */ static _sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Test environment access by listing storage containers * Equivalent to: Get-EpiStorageContainer -ProjectId $id -Environment $env * * @param {string} projectId - Project ID * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {string} environment - Environment name (Integration, Preproduction, Production) * @param {Object} options - Options (apiUrl, etc.) * @returns {Promise<boolean>} True if access granted */ static async testEnvironmentAccess( projectId: string, clientKey: string, clientSecret: string, environment: string, options: RequestOptions = {} ): Promise<boolean> { try { const uriEnding = `projects/${projectId}/environments/${environment}/storagecontainers`; const result = await this.makeRequest(clientKey, clientSecret, uriEnding, 'GET', null, options); // If we got a result, we have access return result && (Array.isArray(result) ? result.length >= 0 : true); } catch (error) { const apiError = error as APIError; // Check for access denied errors if (apiError.statusCode === 401 || apiError.statusCode === 403) { return false; } // Check message for access denied const errorMsg = apiError.message.toLowerCase(); if (errorMsg.includes('unauthorized') || errorMsg.includes('forbidden') || errorMsg.includes('access denied')) { return false; } // Any other error, assume no access OutputLogger.debug(`Environment access test error (${environment}): ${apiError.message}`); return false; } } /** * Get list of deployments * Equivalent to: Get-EpiDeployment -ProjectId $id * * @param {string} projectId - Project ID * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {string} deploymentId - Optional deployment ID * @param {Object} options - Options (apiUrl, etc.) * @returns {Promise<Object>} Deployment(s) */ static async getDeployments( projectId: string, clientKey: string, clientSecret: string, deploymentId: string | null = null, options: RequestOptions = {} ): Promise<any> { const uriEnding = deploymentId ? `projects/${projectId}/deployments/${deploymentId}` : `projects/${projectId}/deployments`; return await this.makeRequest(clientKey, clientSecret, uriEnding, 'GET', null, options); } /** * Get storage containers for an environment * Equivalent to: Get-EpiStorageContainer -ProjectId $id -Environment $env * * @param {string} projectId - Project ID * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {string} environment - Environment name * @param {boolean} writable - Only return writable containers * @param {Object} options - Options (apiUrl, etc.) * @returns {Promise<Array>} List of storage containers */ static async getStorageContainers( projectId: string, clientKey: string, clientSecret: string, environment: string, writable: boolean = false, options: RequestOptions = {} ): Promise<any[]> { const uriEnding = `projects/${projectId}/environments/${environment}/storagecontainers${writable ? '?writable=true' : ''}`; return await this.makeRequest(clientKey, clientSecret, uriEnding, 'GET', null, options); } /** * Start a deployment * Equivalent to: Start-EpiDeployment * * @param {string} projectId - Project ID * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {Object} deploymentParams - Deployment parameters * @param {Object} options - Options (apiUrl, etc.) * @returns {Promise<Object>} Deployment details */ static async startDeployment( projectId: string, clientKey: string, clientSecret: string, deploymentParams: DeploymentParams, options: RequestOptions = {} ): Promise<any> { const uriEnding = `projects/${projectId}/deployments`; return await this.makeRequest(clientKey, clientSecret, uriEnding, 'POST', deploymentParams, options); } /** * Complete a deployment * Equivalent to: Complete-EpiDeployment * * @param {string} projectId - Project ID * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {string} deploymentId - Deployment ID to complete * @param {Object} options - Options (apiUrl, etc.) * @returns {Promise<Object>} Deployment details */ static async completeDeployment( projectId: string, clientKey: string, clientSecret: string, deploymentId: string, options: RequestOptions = {} ): Promise<any> { const uriEnding = `projects/${projectId}/deployments/${deploymentId}/complete`; return await this.makeRequest(clientKey, clientSecret, uriEnding, 'POST', {}, options); } /** * Reset a deployment * Equivalent to: Reset-EpiDeployment * * @param {string} projectId - Project ID * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {string} deploymentId - Deployment ID to reset * @param {Object} resetOptions - Reset options (RollbackDatabase, ValidateBeforeSwap, Complete) * @param {Object} options - Options (apiUrl, etc.) * @returns {Promise<Object>} Deployment details */ static async resetDeployment( projectId: string, clientKey: string, clientSecret: string, deploymentId: string, resetOptions: ResetOptions = {}, options: RequestOptions = {} ): Promise<any> { const uriEnding = `projects/${projectId}/deployments/${deploymentId}/reset`; return await this.makeRequest(clientKey, clientSecret, uriEnding, 'POST', resetOptions, options); } /** * Get package upload location (SAS URL) * Equivalent to: Get-EpiDeploymentPackageLocation * * @param {string} projectId - Project ID * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {Object} options - Options (apiUrl, etc.) * @returns {Promise<string>} SAS URL for package upload */ static async getPackageLocation( projectId: string, clientKey: string, clientSecret: string, options: RequestOptions = {} ): Promise<string> { const uriEnding = `projects/${projectId}/packages/location`; const result = await this.makeRequest(clientKey, clientSecret, uriEnding, 'GET', null, options); // API returns { location: "sas-url" } - return just the URL return result.location || result; } /** * Get SAS link for storage container * Equivalent to: Get-EpiStorageContainerSasLink * * @param {string} projectId - Project ID * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {string} environment - Environment name * @param {string} containerName - Storage container name * @param {Object} sasOptions - SAS link options (RetentionHours, Writable) * @param {Object} options - Options (apiUrl, etc.) * @returns {Promise<Object>} SAS link details */ static async getContainerSasLink( projectId: string, clientKey: string, clientSecret: string, environment: string, containerName: string, sasOptions: SasOptions = {}, options: RequestOptions = {} ): Promise<any> { const uriEnding = `projects/${projectId}/environments/${environment}/storagecontainers/${containerName}/saslink`; const payload = { RetentionHours: sasOptions.retentionHours || sasOptions.RetentionHours || 24, Writable: sasOptions.writable || sasOptions.Writable || false }; return await this.makeRequest(clientKey, clientSecret, uriEnding, 'POST', payload, options); } /** * Start database export * Equivalent to: Start-EpiDatabaseExport * * @param {string} projectId - Project ID * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {string} environment - Environment name * @param {string} databaseName - Database name (epicms or epicommerce) * @param {number} retentionHours - Retention hours for export (default: 24) * @param {Object} options - Options (apiUrl, etc.) * @returns {Promise<Object>} Export details with ID */ static async startDatabaseExport( projectId: string, clientKey: string, clientSecret: string, environment: string, databaseName: string, retentionHours: number = 24, options: RequestOptions = {} ): Promise<any> { const uriEnding = `projects/${projectId}/environments/${environment}/databases/${databaseName}/exports`; const payload = { RetentionHours: retentionHours }; return await this.makeRequest(clientKey, clientSecret, uriEnding, 'POST', payload, options); } /** * Get database export status * Equivalent to: Get-EpiDatabaseExport * * @param {string} projectId - Project ID * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {string} environment - Environment name * @param {string} databaseName - Database name * @param {string} exportId - Export ID * @param {Object} options - Options (apiUrl, etc.) * @returns {Promise<Object>} Export status and download URL */ static async getDatabaseExportStatus( projectId: string, clientKey: string, clientSecret: string, environment: string, databaseName: string, exportId: string, options: RequestOptions = {} ): Promise<any> { const uriEnding = `projects/${projectId}/environments/${environment}/databases/${databaseName}/exports/${exportId}`; return await this.makeRequest(clientKey, clientSecret, uriEnding, 'GET', null, options); } /** * Get all database exports for an environment * DXP-76-2: List all exports with filtering support * * @param {string} projectId - Project ID * @param {string} clientKey - API client key * @param {string} clientSecret - API client secret * @param {string} environment - Environment name * @param {string} databaseName - Database name * @param {Object} options - Options (apiUrl, etc.) * @returns {Promise<Array>} Array of exports */ static async getDatabaseExports( projectId: string, clientKey: string, clientSecret: string, environment: string, databaseName: string, options: RequestOptions = {} ): Promise<any> { const uriEnding = `projects/${projectId}/environments/${environment}/databases/${databaseName}/exports`; return await this.makeRequest(clientKey, clientSecret, uriEnding, 'GET', null, options); } } export default DXPRestClient;

Implementation Reference

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/JaxonDigital/optimizely-dxp-mcp'

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