@contiva/sap-integration-suite-client
Version:
SAP Cloud Platform Integration API Client
949 lines • 65.3 kB
JavaScript
"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(