UNPKG

@contiva/sap-integration-suite-client

Version:
949 lines 65.3 kB
"use strict"; /** * SAP Cloud Platform Integration API Client * * This module provides a client for interacting with the SAP Cloud Platform Integration API. * It handles authentication, request management, and provides type-safe access to all SAP API endpoints. * * @module sap-integration-suite-client * @packageDocumentation */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const axios_1 = __importDefault(require("axios")); const dotenv_1 = require("dotenv"); const sap_IntegrationContent_1 = require("../types/sap.IntegrationContent"); const sap_LogFiles_1 = require("../types/sap.LogFiles"); const sap_MessageProcessingLogs_1 = require("../types/sap.MessageProcessingLogs"); const sap_MessageStore_1 = require("../types/sap.MessageStore"); const sap_SecurityContent_1 = require("../types/sap.SecurityContent"); const sap_B2BScenarios_1 = require("../types/sap.B2BScenarios"); const sap_PartnerDirectory_1 = require("../types/sap.PartnerDirectory"); const integration_content_client_1 = require("../wrapper/integration-content-client"); const integration_content_advanced_client_1 = require("../wrapper/custom/integration-content-advanced-client"); const message_processing_logs_client_1 = require("../wrapper/message-processing-logs-client"); const log_files_client_1 = require("../wrapper/log-files-client"); const message_store_client_1 = require("../wrapper/message-store-client"); const security_content_client_1 = require("../wrapper/security-content-client"); const b2b_scenarios_client_1 = require("../wrapper/b2b-scenarios-client"); const partner_directory_client_1 = require("../wrapper/partner-directory-client"); const custom_client_registry_1 = require("../wrapper/custom/custom-client-registry"); const cache_manager_1 = require("../core/cache-manager"); const hostname_extractor_1 = require("../utils/hostname-extractor"); const cache_key_generator_1 = require("../utils/cache-key-generator"); const cache_config_1 = require("../core/cache-config"); // Load environment variables from .env file (0, dotenv_1.config)(); /** * Main SAP Client class that provides access to all SAP API endpoints * * This client handles: * - Authentication via OAuth 2.0 client credentials flow * - Token management (expiration and renewal) * - CSRF token handling for write operations * - Type-safe API access through generated API clients * - Error handling * - Response format normalization * * @example * // Basic usage with environment variables * import SapClient from 'sap-integration-suite-client'; * const client = new SapClient(); * * // With explicit configuration * const client = new SapClient({ * baseUrl: 'https://tenant.sap-api.com/api/v1', * oauthClientId: 'client-id', * oauthClientSecret: 'client-secret', * oauthTokenUrl: 'https://tenant.authentication.sap.hana.ondemand.com/oauth/token' * }); * * // Making API calls * async function getPackages() { * const response = await client.integrationContent.integrationPackages.integrationPackagesList(); * return response.data; * } */ class SapClient { /** * Gets the cache manager instance * Returns null if caching is disabled or not configured * * @returns The CacheManager instance or null */ get cacheManager() { return this._cacheManager; } /** * Creates a new SAP Client instance * * @param {SapClientConfig} [config] - Configuration options for the client * @throws {Error} If required configuration is missing * * @example * // Using environment variables * const client = new SapClient(); * * @example * // Using explicit configuration * const client = new SapClient({ * baseUrl: 'https://tenant.sap-api.com/api/v1', * oauthClientId: 'client-id', * oauthClientSecret: 'client-secret', * oauthTokenUrl: 'https://tenant.authentication.sap.hana.ondemand.com/oauth/token' * }); */ constructor(config) { /** CSRF token for write operations */ this.csrfToken = null; /** Promise for ongoing CSRF token fetch to prevent race conditions */ this.csrfTokenPromise = null; /** OAuth token with expiration information */ this.oauthToken = null; /** Promise for ongoing token refresh to prevent race conditions */ this.tokenRefreshPromise = null; /** Whether to normalize API responses */ this.normalizeResponses = true; /** Maximum number of retries for failed requests */ this.maxRetries = 3; /** Delay between retries in milliseconds */ this.retryDelay = 1000; /** Minimum delay between consecutive requests (rate limiting prevention) */ this.minRequestDelay = 50; // 50ms minimum between requests /** Timestamp of last request */ this.lastRequestTime = 0; /** Whether to enable custom client extensions */ this.enableCustomClients = true; /** Map der erstellten benutzerdefinierten Clients */ this.customClients = new Map(); /** Cache manager for Redis-based caching */ this._cacheManager = null; /** Flag to track if cache manager is externally provided (don't disconnect on cleanup) */ this.isExternalCacheManager = false; /** Force cache revalidation on every request */ this.forceRefreshCache = false; /** Disable caching completely */ this.noCache = false; // Load configuration with priority: passed config > environment variables > empty string this.baseUrl = (config === null || config === void 0 ? void 0 : config.baseUrl) || process.env.SAP_BASE_URL || ''; // Ensure the base URL includes the API path '/api/v1' if (this.baseUrl && !this.baseUrl.endsWith('/api/v1') && !this.baseUrl.includes('/api/v1/')) { // Remove trailing slash if present this.baseUrl = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl; // Add /api/v1 this.baseUrl += '/api/v1'; } this.oauthClientId = (config === null || config === void 0 ? void 0 : config.oauthClientId) || process.env.SAP_OAUTH_CLIENT_ID || ''; this.oauthClientSecret = (config === null || config === void 0 ? void 0 : config.oauthClientSecret) || process.env.SAP_OAUTH_CLIENT_SECRET || ''; this.oauthTokenUrl = (config === null || config === void 0 ? void 0 : config.oauthTokenUrl) || process.env.SAP_OAUTH_TOKEN_URL || ''; this.normalizeResponses = (config === null || config === void 0 ? void 0 : config.normalizeResponses) !== undefined ? config.normalizeResponses : true; this.maxRetries = (config === null || config === void 0 ? void 0 : config.maxRetries) !== undefined ? config.maxRetries : 3; this.retryDelay = (config === null || config === void 0 ? void 0 : config.retryDelay) !== undefined ? config.retryDelay : 1000; this.enableCustomClients = (config === null || config === void 0 ? void 0 : config.enableCustomClients) !== undefined ? config.enableCustomClients : true; this.customClientRegistry = custom_client_registry_1.CustomClientRegistry.getInstance(); // Redis caching configuration const redisConnectionString = (config === null || config === void 0 ? void 0 : config.redisConnectionString) || process.env.REDIS_CONNECTION_STRING || ''; const redisEnabled = (config === null || config === void 0 ? void 0 : config.redisEnabled) !== undefined ? config.redisEnabled : (process.env.REDIS_ENABLED === 'true'); this.forceRefreshCache = (config === null || config === void 0 ? void 0 : config.forceRefreshCache) || false; this.noCache = (config === null || config === void 0 ? void 0 : config.noCache) || false; this.cacheLogger = config === null || config === void 0 ? void 0 : config.cacheLogger; // Cache debug mode flag for performance (avoid repeated process.env access) this.debugMode = process.env.DEBUG === 'true'; // Extract hostname for cache key generation this.hostname = (0, hostname_extractor_1.extractHostname)(this.baseUrl); // Initialize cache manager if (!this.noCache) { // Option 1: Use external cache manager if provided (connection pooling) if (config === null || config === void 0 ? void 0 : config.cacheManager) { this._cacheManager = config.cacheManager; this.isExternalCacheManager = true; if (this.debugMode) { console.debug('[SapClient] Using external CacheManager instance (connection pooling)'); } } // Option 2: Create new cache manager if Redis is enabled else if (redisEnabled && redisConnectionString && redisConnectionString.trim().length > 0) { // Use OAuth client secret for encryption if available this._cacheManager = new cache_manager_1.CacheManager(redisConnectionString, true, this.oauthClientSecret // Use client secret for cache encryption ); this.isExternalCacheManager = false; // Connect to Redis asynchronously (non-blocking) this._cacheManager.connect().catch((err) => { console.error('[SapClient] Failed to connect to Redis:', err); // Disable cache manager if connection fails if (this._cacheManager) { this._cacheManager.disable(); } this._cacheManager = null; }); if (this.debugMode) { console.debug('[SapClient] Created new CacheManager instance'); } } } if (this.debugMode) { console.debug('[SapClient] Initializing with config:', { baseUrl: this.baseUrl, normalizeResponses: this.normalizeResponses, maxRetries: this.maxRetries, retryDelay: this.retryDelay, cacheEnabled: !!this._cacheManager, forceRefreshCache: this.forceRefreshCache, noCache: this.noCache }); } // Validate required configuration this.validateConfig(); // Create axios instance without auth (auth will be added via interceptor) this.axiosInstance = axios_1.default.create({ baseURL: this.baseUrl, headers: { 'Content-Type': 'application/json', }, }); // Add request interceptor to add OAuth token to every request this.axiosInstance.interceptors.request.use(async (config) => { // Get or refresh OAuth token const token = await this.getOAuthToken(); config.headers.Authorization = `Bearer ${token.access_token}`; return config; }, (error) => { return Promise.reject(error); }); // Add response interceptor to normalize response format if enabled if (this.normalizeResponses) { this.axiosInstance.interceptors.response.use((response) => { var _a; // Normalize response data format if (response.data) { const originalData = JSON.stringify(response.data).substring(0, 100); response.data = this.normalizeResponseFormat(response.data); // Extended debug logging to track what's happening with the normalization if (this.debugMode) { const normalizedData = JSON.stringify(response.data).substring(0, 100); console.debug(`[SapClient] Response normalized for ${(_a = response.config.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()} ${response.config.url}`, `\nBefore: ${originalData}${originalData.length >= 100 ? '...' : ''}`, `\nAfter: ${normalizedData}${normalizedData.length >= 100 ? '...' : ''}`); } } return response; }, (error) => { return Promise.reject(this.enhanceError(error)); }); } // Add response interceptor to handle token expiration (401 Unauthorized) and CSRF token expiration (403 Forbidden) this.axiosInstance.interceptors.response.use((response) => response, async (error) => { var _a, _b; const originalRequest = error.config; // Check if error is due to invalid OAuth token (401) and this request hasn't been retried yet if (((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 401 && !originalRequest._retryAuth) { console.debug('OAuth token expired or invalid, attempting to refresh...'); // Mark request as auth retried to prevent infinite loops originalRequest._retryAuth = true; // Invalidate current token this.oauthToken = null; try { // Get a fresh token const token = await this.getOAuthToken(); // Update the Authorization header with the new token originalRequest.headers.Authorization = `Bearer ${token.access_token}`; // Retry the original request with the new token return this.axiosInstance(originalRequest); } catch (tokenError) { // If token refresh also fails, reject with the token error return Promise.reject(this.enhanceError(tokenError, 'Failed to refresh token after 401 Unauthorized')); } } // Check if error is due to invalid CSRF token (403 Forbidden) for write operations if (((_b = error.response) === null || _b === void 0 ? void 0 : _b.status) === 403 && originalRequest.method && ['post', 'put', 'delete'].includes(originalRequest.method.toLowerCase()) && !originalRequest._retryCsrf) { console.debug('CSRF token expired or invalid, attempting to refresh...'); // Mark request as CSRF retried to prevent infinite loops originalRequest._retryCsrf = true; // Invalidate current CSRF token this.csrfToken = null; try { // Get a fresh CSRF token await this.ensureCsrfToken(true); // Force refresh // Update the X-CSRF-Token header with the new token if (this.csrfToken) { originalRequest.headers['X-CSRF-Token'] = this.csrfToken; } // Retry the original request with the new CSRF token return this.axiosInstance(originalRequest); } catch (csrfError) { // If CSRF token refresh also fails, reject with the error return Promise.reject(this.enhanceError(csrfError, 'Failed to refresh CSRF token after 403 Forbidden')); } } // For other errors, just reject with the original error return Promise.reject(error); }); // Initialize API clients const integrationContentApiClient = new sap_IntegrationContent_1.Api({ baseUrl: this.baseUrl, customFetch: this.customFetch.bind(this), securityWorker: async () => { const token = await this.getOAuthToken(); return { headers: { 'Authorization': `Bearer ${token.access_token}` } }; } }); this.integrationContent = new integration_content_client_1.IntegrationContentClient(integrationContentApiClient, this._cacheManager, this.hostname); const logFilesApi = new sap_LogFiles_1.Api({ baseUrl: this.baseUrl, customFetch: this.customFetch.bind(this), }); this.logFiles = new log_files_client_1.LogFilesClient(logFilesApi); const messageProcessingLogsApi = new sap_MessageProcessingLogs_1.Api({ baseUrl: this.baseUrl, customFetch: this.customFetch.bind(this), }); this.messageProcessingLogs = new message_processing_logs_client_1.MessageProcessingLogsClient(messageProcessingLogsApi); const messageStoreApi = new sap_MessageStore_1.Api({ baseUrl: this.baseUrl, customFetch: this.customFetch.bind(this), }); this.messageStore = new message_store_client_1.MessageStoreClient(messageStoreApi); const securityContentApi = new sap_SecurityContent_1.Api({ baseUrl: this.baseUrl, customFetch: this.customFetch.bind(this), }); // Pass cacheManager and hostname to SecurityContentClient for WithCache methods this.securityContent = new security_content_client_1.SecurityContentClient(securityContentApi, this._cacheManager, this.hostname); const b2bScenariosApi = new sap_B2BScenarios_1.Api({ baseUrl: this.baseUrl, customFetch: this.customFetch.bind(this), }); this.b2bScenarios = new b2b_scenarios_client_1.B2BScenariosClient(b2bScenariosApi); const partnerDirectoryApi = new sap_PartnerDirectory_1.Api({ baseUrl: this.baseUrl, customFetch: this.customFetch.bind(this), }); this.partnerDirectory = new partner_directory_client_1.PartnerDirectoryClient(partnerDirectoryApi); // Custom Clients initialisieren if (this.enableCustomClients) { this.initializeCustomClients(); } else { // Legacy-Initialisierung für Kompatibilität, wenn Custom-Clients deaktiviert sind this.integrationContentAdvanced = new integration_content_advanced_client_1.IntegrationContentAdvancedClient(this.integrationContent); // MessageProcessingLogsAdvancedClient wird on-demand erstellt, wenn er benötigt wird } } /** * Validates that the required configuration is provided * * @private * @throws {Error} If baseUrl is missing * @throws {Error} If any OAuth configuration is incomplete */ validateConfig() { if (!this.baseUrl) { throw new Error('Base URL is required. Provide it via constructor parameter (baseUrl) or SAP_BASE_URL environment variable.'); } if (!this.oauthClientId || !this.oauthClientSecret || !this.oauthTokenUrl) { const missing = []; if (!this.oauthClientId) missing.push('oauthClientId / SAP_OAUTH_CLIENT_ID'); if (!this.oauthClientSecret) missing.push('oauthClientSecret / SAP_OAUTH_CLIENT_SECRET'); if (!this.oauthTokenUrl) missing.push('oauthTokenUrl / SAP_OAUTH_TOKEN_URL'); throw new Error(`OAuth configuration is incomplete. Missing: ${missing.join(', ')}. Provide these via constructor parameters or environment variables.`); } } /** * Gets or refreshes the OAuth token * - Returns existing token if it's still valid * - Fetches a new token if the current one is expired or doesn't exist * - Adds expiration timestamp to the token * * @private * @returns {Promise<OAuthToken>} Promise resolving to the OAuth token * @throws {Error} If token cannot be obtained */ async getOAuthToken() { const now = Date.now(); // If we already have a token and it's not expired, return it // We add a 1-minute buffer before expiration to be safe if (this.oauthToken && this.oauthToken.expiresAt && this.oauthToken.expiresAt > now + 60000) { return this.oauthToken; } // If a token refresh is already in progress, wait for it if (this.tokenRefreshPromise) { return this.tokenRefreshPromise; } // Otherwise, get a new token this.tokenRefreshPromise = (async () => { try { // Use params like in the direct implementation instead of basic auth const tokenResponse = await axios_1.default.post(this.oauthTokenUrl, null, { params: { grant_type: 'client_credentials', client_id: this.oauthClientId, client_secret: this.oauthClientSecret }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); const token = tokenResponse.data; // Set expiration time (subtract 5 minutes for safety margin) token.expiresAt = now + (token.expires_in * 1000) - 300000; this.oauthToken = token; this.tokenRefreshPromise = null; // Clear the promise after success return token; } catch (error) { this.tokenRefreshPromise = null; // Clear the promise on error throw this.enhanceError(error, 'Failed to obtain OAuth token'); } })(); return this.tokenRefreshPromise; } /** * Normalizes different SAP API response formats into a consistent structure * * @private * @param {any} data - The response data to normalize * @returns {any} Normalized response data */ normalizeResponseFormat(data) { if (!data) return data; // Handle array responses (already normalized) if (Array.isArray(data)) { if (this.debugMode) { console.debug('[SapClient] Format detected: Already array', `(length: ${data.length})`); } return data; } // Handle OData v2 format (d.results) if (data.d && Array.isArray(data.d.results)) { if (this.debugMode) { console.debug('[SapClient] Format detected: OData v2 (d.results)', `(length: ${data.d.results.length})`); } return data.d.results; } // Handle OData v2 format where d is directly the object (not an array) if (data.d && typeof data.d === 'object' && !Array.isArray(data.d) && !data.d.results) { if (this.debugMode) { console.debug('[SapClient] Format detected: OData v2 (d as object)', `(keys: ${Object.keys(data.d).join(', ')})`); } return data.d; } // Handle OData v4 format (value array) if (data.value && Array.isArray(data.value)) { if (this.debugMode) { console.debug('[SapClient] Format detected: OData v4 (value array)', `(length: ${data.value.length})`); } return data.value; } // Handle IntegrationPackages format if (data.IntegrationPackages && Array.isArray(data.IntegrationPackages)) { if (this.debugMode) { console.debug('[SapClient] Format detected: IntegrationPackages array', `(length: ${data.IntegrationPackages.length})`); } return data.IntegrationPackages; } // Handle results property directly (some APIs return { results: [...] }) if (data.results && Array.isArray(data.results)) { if (this.debugMode) { console.debug('[SapClient] Format detected: Direct results array', `(length: ${data.results.length})`); } return data.results; } // Handle specific nested formats that may contain arrays if (data.d && data.d.__next && data.d.results) { if (this.debugMode) { console.debug('[SapClient] Format detected: OData with __next link', `(length: ${data.d.results.length})`); } return data.d.results; // OData with __next link } // If the data is an object and has only one property that is an array, return that array const keys = Object.keys(data); if (keys.length === 1 && Array.isArray(data[keys[0]])) { if (this.debugMode) { console.debug('[SapClient] Format detected: Single property array', `(property: ${keys[0]}, length: ${data[keys[0]].length})`); } return data[keys[0]]; } // Special handling for OData queries with $count that return a string if (typeof data === 'string' && !isNaN(parseInt(data, 10))) { if (this.debugMode) { console.debug('[SapClient] Format detected: Count string value', `(value: ${data})`); } return data; // Keep the string for count operations } // If we can't normalize, return the original data if (this.debugMode) { console.debug('[SapClient] No normalization applied, returning original data', `(type: ${typeof data}, keys: ${typeof data === 'object' ? Object.keys(data).join(', ') : 'n/a'})`); } return data; } /** * Enhances error objects with more detailed information * * @private * @param {any} error - The error object * @param {string} [message] - Optional message to prefix the error * @returns {any} Enhanced error object */ enhanceError(error, message) { var _a, _b, _c, _d, _e, _f, _g, _h; // For 429 errors, create a simplified error to reduce log noise if (((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 429) { const enhancedError = new Error(message ? `${message}: Rate limit exceeded (429)` : 'Rate limit exceeded (429)'); // Add only essential properties for 429 errors enhancedError.statusCode = 429; enhancedError.statusText = 'Too Many Requests'; // Add Retry-After header if present const retryAfter = ((_c = (_b = error.response) === null || _b === void 0 ? void 0 : _b.headers) === null || _c === void 0 ? void 0 : _c['retry-after']) || ((_e = (_d = error.response) === null || _d === void 0 ? void 0 : _d.headers) === null || _e === void 0 ? void 0 : _e['Retry-After']); if (retryAfter) { enhancedError.retryAfter = retryAfter; } return enhancedError; } // For other errors, create a full enhanced error const enhancedError = new Error(message ? `${message}: ${error.message}` : error.message); // Copy all properties from the original error Object.assign(enhancedError, error); // Add additional helpful properties if they exist if (error.response) { enhancedError.statusCode = error.response.status; enhancedError.statusText = error.response.statusText; enhancedError.responseData = error.response.data; // Special handling for OData error details const odataError = ((_f = error.response.data) === null || _f === void 0 ? void 0 : _f.error) || ((_g = error.response.data) === null || _g === void 0 ? void 0 : _g['odata.error']) || (((_h = error.response.data) === null || _h === void 0 ? void 0 : _h.d) && error.response.data.d.error); if (odataError) { enhancedError.odataError = true; // Extract common OData error properties const errorCode = odataError.code || odataError.Code; const errorMessage = odataError.message || odataError.Message; const details = odataError.details || odataError.Details || odataError.innererror; // Add extracted properties to the error if (errorCode) enhancedError.errorCode = errorCode; if (errorMessage) { // OData can include message as an object with 'value' property or directly as string if (typeof errorMessage === 'object' && errorMessage.value) { enhancedError.errorMessage = errorMessage.value; } else { enhancedError.errorMessage = errorMessage; } } if (details) enhancedError.errorDetails = details; // Add a formatted message that includes OData error details enhancedError.message = `${enhancedError.message} (OData Error: ${enhancedError.errorCode || 'Unknown Code'}: ${enhancedError.errorMessage || 'No message provided'})`; } } return enhancedError; } /** * Performs an HTTP request with retry logic for transient errors * * @private * @param {Function} requestFn - Function that performs the request * @param {number} retries - Number of retries left * @returns {Promise<any>} Promise resolving to the response */ async requestWithRetry(requestFn, retries = this.maxRetries) { // Rate limiting prevention: Wait if needed before making the request const now = Date.now(); const timeSinceLastRequest = now - this.lastRequestTime; if (timeSinceLastRequest < this.minRequestDelay) { const waitTime = this.minRequestDelay - timeSinceLastRequest; await new Promise(resolve => setTimeout(resolve, waitTime)); } // Update last request time this.lastRequestTime = Date.now(); try { return await requestFn(); } catch (error) { // Only retry if we have retries left and it's a retryable error const isRetryable = error.response && (error.response.status >= 500 || // Server errors error.response.status === 429); // Rate limiting if (retries > 0 && isRetryable) { // Calculate delay with exponential backoff // Iteration 1 (retries=max): 2^0 * delay = 1 * delay // Iteration 2 (retries=max-1): 2^1 * delay = 2 * delay // Iteration 3 (retries=max-2): 2^2 * delay = 4 * delay let delay = this.retryDelay * Math.pow(2, this.maxRetries - retries); // If 429 and Retry-After header is present, use it if (error.response.status === 429 && error.response.headers) { // Header lookup should be case-insensitive, but axios headers are usually lowercase const retryAfter = error.response.headers['retry-after'] || error.response.headers['Retry-After']; if (retryAfter) { // Retry-After can be seconds or HTTP date if (/^\d+$/.test(String(retryAfter))) { // It's seconds delay = parseInt(String(retryAfter), 10) * 1000; } else { // Try to parse as date const date = Date.parse(String(retryAfter)); if (!isNaN(date)) { delay = Math.max(0, date - Date.now()); } } } } // Add some jitter (randomness 0-100ms) to prevent thundering herd const jitter = Math.random() * 100; delay += jitter; // Simplified logging for 429 errors if (error.response.status === 429) { console.warn(`[SapClient] Rate limit hit (429). Retrying in ${Math.round(delay)}ms... (${retries} retries left)`); } else if (this.debugMode) { console.debug(`[SapClient] Request failed with status ${error.response.status}. Retrying in ${Math.round(delay)}ms... (${retries} retries left)`); } // Wait before retrying await new Promise(resolve => setTimeout(resolve, delay)); // Retry with one less retry attempt return this.requestWithRetry(requestFn, retries - 1); } // If we can't retry, re-throw the error throw error; } } /** * Checks if a URL represents a collection endpoint (without specific ID) * Collection endpoints return arrays of items and are candidates for differential updates * * @private * @param url - The URL to check * @returns true if URL is a collection endpoint, false otherwise * * @example * isCollectionEndpoint('https://api.com/IntegrationRuntimeArtifacts') // true * isCollectionEndpoint('https://api.com/IntegrationRuntimeArtifacts(\'id\')') // false * isCollectionEndpoint('https://api.com/IntegrationPackages') // true */ isCollectionEndpoint(url) { try { // Collection patterns from cache-collections-config.ts const collectionPatterns = [ '/IntegrationRuntimeArtifacts', '/IntegrationPackages', '/IntegrationDesigntimeArtifacts', '/MessageMappingDesigntimeArtifacts', '/ValueMappingDesigntimeArtifacts', '/ScriptCollectionDesigntimeArtifacts', ]; // Check if URL contains any collection pattern for (const pattern of collectionPatterns) { if (url.includes(pattern)) { // Ensure it's not a specific item request (no ID in parentheses) // Pattern: /IntegrationRuntimeArtifacts('SomeId') should return false // Pattern: /IntegrationRuntimeArtifacts or /IntegrationRuntimeArtifacts?$filter=... should return true // Extract the part after the pattern const afterPattern = url.substring(url.indexOf(pattern) + pattern.length); // If there's nothing after, or it starts with ? or &, it's a collection if (!afterPattern || afterPattern.startsWith('?') || afterPattern.startsWith('&')) { return true; } // If it starts with ( followed by a quote, it's a specific item if (afterPattern.match(/^\(['"].*['"]\)/)) { return false; } } } return false; } catch (error) { if (this.debugMode) { console.debug('[SapClient] Error checking if URL is collection endpoint:', error); } return false; } } /** * Extracts the base endpoint pattern for cache invalidation * * This method determines which cache pattern should be invalidated based on the endpoint. * For example: * - /IntegrationPackages('MyPackage')/IntegrationDesigntimeArtifacts → /IntegrationPackages * - /IntegrationRuntimeArtifacts('MyArtifact') → /IntegrationRuntimeArtifacts * - /MessageMappingDesigntimeArtifacts → /MessageMappingDesigntimeArtifacts * * @private * @param endpoint - The full endpoint path (without base URL) * @returns The base endpoint pattern for invalidation */ extractBaseEndpointForInvalidation(endpoint) { // Remove query parameters const pathOnly = endpoint.split('?')[0]; // Known base endpoints that should be invalidated together const designtimeEndpoints = [ '/IntegrationPackages', '/IntegrationDesigntimeArtifacts', '/MessageMappingDesigntimeArtifacts', '/ValueMappingDesigntimeArtifacts', '/ScriptCollectionDesigntimeArtifacts', ]; const runtimeEndpoints = [ '/IntegrationRuntimeArtifacts', ]; // Check if endpoint contains any designtime pattern for (const base of designtimeEndpoints) { if (pathOnly.includes(base)) { return base; } } // Check if endpoint contains any runtime pattern for (const base of runtimeEndpoints) { if (pathOnly.includes(base)) { return base; } } // Fallback: extract the first path segment after /api/v1/ // e.g., /api/v1/SomeEndpoint('id')/SubPath → /SomeEndpoint const match = pathOnly.match(/\/([A-Za-z]+)(?:\(|\/|$)/); if (match) { return '/' + match[1]; } // If all else fails, return the path up to the first parenthesis or query const basicPath = pathOnly.replace(/\([^)]*\).*$/, ''); return basicPath || pathOnly; } /** * Custom fetch implementation that uses axios to make HTTP requests * - Handles CSRF token for write operations * - Adds OAuth token to requests * - Transforms axios responses to fetch API Response objects * - Implements retry logic * - Normalizes response formats * - Implements Redis-based caching with stale-while-revalidate pattern * * @private * @param {string | URL | Request} input - URL or Request object * @param {RequestInit} [init] - Request initialization options * @returns {Promise<Response>} Promise resolving to a Response object * @throws {Error} If the request fails */ async customFetch(input, init) { var _a; try { // For any operation that might need a CSRF token if ((init === null || init === void 0 ? void 0 : init.method) && ['POST', 'PUT', 'DELETE'].includes(init.method)) { await this.ensureCsrfToken(); if (init.headers && this.csrfToken) { init.headers['X-CSRF-Token'] = this.csrfToken; } } const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; const method = (init === null || init === void 0 ? void 0 : init.method) || 'GET'; const headers = init === null || init === void 0 ? void 0 : init.headers; // Parse body safely - catch JSON parsing errors let body = undefined; if (init === null || init === void 0 ? void 0 : init.body) { try { body = JSON.parse(init.body.toString()); } catch (_b) { // If parsing fails, use body as-is (might be FormData, Blob, etc.) body = init.body; } } // Ensure Redis is connected if we have a cache manager if (this._cacheManager && !this._cacheManager.isReady()) { // Try to connect (will be quick if already connected or connecting) await this._cacheManager.connect().catch(() => { // Ignore connection errors - caching will be skipped }); } // Check if caching should be applied const cacheable = (0, cache_config_1.isCacheableUrl)(url); const shouldCache = method === 'GET' && this._cacheManager && this._cacheManager.isReady() && cacheable; if (this.debugMode) { console.log(`[SapClient] Cache check for ${url}: shouldCache=${shouldCache}, method=${method}, cacheManager=${!!this._cacheManager}, isReady=${(_a = this._cacheManager) === null || _a === void 0 ? void 0 : _a.isReady()}, isCacheable=${cacheable}`); } // Extract endpoint for logging const endpoint = url.replace(this.baseUrl, '').split('?')[0]; // Generate cache key if caching is enabled let cacheKey = ''; if (shouldCache) { const queryParams = (0, cache_key_generator_1.parseQueryParams)(url); cacheKey = (0, cache_key_generator_1.generateCacheKey)(this.hostname, method, url, queryParams); // If forceRefreshCache is true, skip cache lookup entirely // Note: Pattern-based cache invalidation should be done BEFORE creating the SapClient // (e.g., via unifiedCacheService.invalidateByPattern in the backend) // Here we just ensure we don't read from cache and fetch fresh data if (this.forceRefreshCache) { const forceRefreshLog = `[SapClient] 🔄 ${endpoint} - Force refresh: skipping cache, fetching fresh data from SAP`; if (this.cacheLogger) { this.cacheLogger(forceRefreshLog); } else { console.log(forceRefreshLog); } // Force refresh: skip the entire cache lookup block and go directly to performRequest // Note: We still want to CACHE the fresh data, so we keep shouldCache = true // but we skip reading from cache below } else { // Try to get from cache (only if not force refreshing) const cachedData = await this._cacheManager.get(cacheKey); if (cachedData) { // Check if cache needs revalidation (either stale or expired) const isExpired = this._cacheManager.isExpired(cachedData); const shouldRevalidate = this._cacheManager.shouldRevalidate(cachedData, this.forceRefreshCache); if (isExpired || shouldRevalidate) { // Cache needs revalidation, use stale-while-revalidate pattern // Return old data immediately and update in background const isExpired = this._cacheManager.isExpired(cachedData); const cacheStatus = isExpired ? 'HIT-EXPIRED' : 'HIT-STALE'; // Log cache status const logMessage = `[SapClient] ${cacheStatus === 'HIT-EXPIRED' ? '⏰' : '🔄'} ${endpoint} - ${cacheStatus === 'HIT-EXPIRED' ? 'Cache expired' : 'Cache stale'} - returning old data and revalidating in background`; if (this.cacheLogger) { this.cacheLogger(logMessage); } else { console.log(logMessage); } if (this.debugMode) { console.log(`[SapClient] Cache ${isExpired ? 'expired' : 'stale'} for: ${url} - returning old data and revalidating in background`); } // Return stale/expired data immediately const staleResponse = new Response(JSON.stringify(cachedData.data), { status: 200, statusText: 'OK (from cache - stale)', headers: new Headers({ 'X-Cache': cacheStatus }), }); // Add cache metadata to response object for logging // @ts-ignore - Adding custom property for cache tracking staleResponse._cacheStatus = cacheStatus; // Start background revalidation const revalidationTime = (0, cache_config_1.getRevalidationTime)(url); const cacheOptions = { ttl: cache_config_1.CACHE_TTL.STANDARD, revalidateAfter: revalidationTime, }; // Start background revalidation with logging const bgStartLog = `[SapClient] 🔄 ${endpoint} - Background revalidation started`; if (this.cacheLogger) { this.cacheLogger(bgStartLog); } else { console.log(bgStartLog); } // Check if this is a collection endpoint for differential updates const isCollection = this.isCollectionEndpoint(url); const enableDifferential = isCollection; // Enable differential for collections if (this.debugMode && enableDifferential) { console.log(`[SapClient] Differential revalidation enabled for collection endpoint: ${endpoint}`); } this._cacheManager.revalidateInBackground(cacheKey, async () => { try { const freshData = await this.performRequest(url, method, headers, body); const successLog = `[SapClient] ✅ ${endpoint} - Background revalidation completed${enableDifferential ? ' (differential)' : ''}`; if (this.cacheLogger) { this.cacheLogger(successLog); } else { console.log(successLog); } return freshData; } catch (error) { const errorLog = `[SapClient] ❌ ${endpoint} - Background revalidation failed: ${error.message}`; if (this.cacheLogger) { this.cacheLogger(errorLog); } else { console.log(errorLog); } throw error; } }, cacheOptions, enableDifferential, isCollection); return staleResponse; } else { // Cache is fresh, return it const hitLog = `[SapClient] ✅ ${endpoint} - Cache HIT (fresh)`; if (this.cacheLogger) { this.cacheLogger(hitLog); } else { console.log(hitLog); } if (this.debugMode) { console.log(`[SapClient] Cache hit (fresh): ${url}`); } const freshResponse = new Response(JSON.stringify(cachedData.data), { status: 200, statusText: 'OK (from cache)', headers: new Headers({ 'X-Cache': 'HIT' }), }); // Add cache metadata to response object for logging // @ts-ignore - Adding custom property for cache tracking freshResponse._cacheStatus = 'HIT'; return freshResponse; } } else { // Cache miss const missLog = `[SapClient] ❌ ${endpoint} - Cache MISS (fetching from SAP)`; if (this.cacheLogger) { this.cacheLogger(missLog); } else { console.log(missLog); } if (this.debugMode) { console.log(`[SapClient] Cache miss: ${url}`); } } } } // Perform the actual request const responseData = await this.performRequest(url, method, headers, body); // Cache the response if caching is enabled if (shouldCache && cacheKey) { const revalidationTime = (0, cache_config_1.getRevalidationTime)(url); const cacheOptions = { ttl: cache_config_1.CACHE_TTL.STANDARD, revalidateAfter: revalidationTime, }; await this._cacheManager.set(cacheKey, responseData, cacheOptions); const cachedLog = `[SapClient] 💾 ${endpoint} - Cached (TTL: ${cache_config_1.CACHE_TTL.STANDARD / 86400}d, revalidate: ${revalidationTime / 3600}h)`; if (this.cacheLogger) { this.cacheLogger(cachedLog); } else { console.log(cachedLog); } if (this.debugMode) { console.log(`[SapClient] Cached response with key: ${cacheKey}`); } } // Convert to fetch Response with cache metadata const response = new Response(