UNPKG

@contiva/sap-integration-suite-client

Version:
609 lines (608 loc) 29.4 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 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 custom_client_registry_1 = require("../wrapper/custom/custom-client-registry"); // 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 { /** * 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; /** OAuth token with expiration information */ this.oauthToken = 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; /** Whether to enable custom client extensions */ this.enableCustomClients = true; /** Map der erstellten benutzerdefinierten Clients */ this.customClients = new Map(); // 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) || 0; this.retryDelay = (config === null || config === void 0 ? void 0 : 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(); // Enable debug mode if environment variable is set const debugMode = process.env.DEBUG === 'true'; if (debugMode) { console.debug('[SapClient] Initializing with config:', { baseUrl: this.baseUrl, normalizeResponses: this.normalizeResponses, maxRetries: this.maxRetries, retryDelay: this.retryDelay }); } // 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 (process.env.DEBUG === 'true') { 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) this.axiosInstance.interceptors.response.use((response) => response, async (error) => { var _a; const originalRequest = error.config; // Check if error is due to invalid token (401) and this request hasn't been retried yet if (((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 401 && !originalRequest._retry) { console.debug('Token expired or invalid, attempting to refresh...'); // Mark request as retried to prevent infinite loops originalRequest._retry = 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')); } } // 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), }); this.integrationContent = new integration_content_client_1.IntegrationContentClient(integrationContentApiClient); 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), }); this.securityContent = new security_content_client_1.SecurityContentClient(securityContentApi); // 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; } // Otherwise, get a new token 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; return token; } catch (error) { throw this.enhanceError(error, 'Failed to obtain OAuth token'); } } /** * 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; // Create a debug info object to help with troubleshooting const debugInfo = { originalType: typeof data, isArray: Array.isArray(data), topLevelKeys: typeof data === 'object' ? Object.keys(data) : [] }; // Handle array responses (already normalized) if (Array.isArray(data)) { if (process.env.DEBUG === 'true') { 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 (process.env.DEBUG === 'true') { 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 (process.env.DEBUG === 'true') { 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 (process.env.DEBUG === 'true') { 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 (process.env.DEBUG === 'true') { 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 (process.env.DEBUG === 'true') { 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 (process.env.DEBUG === 'true') { 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 (process.env.DEBUG === 'true') { 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 (process.env.DEBUG === 'true') { 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 (process.env.DEBUG === 'true') { 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; // Create a new error with a more descriptive message 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 = ((_a = error.response.data) === null || _a === void 0 ? void 0 : _a.error) || ((_b = error.response.data) === null || _b === void 0 ? void 0 : _b['odata.error']) || (((_c = error.response.data) === null || _c === void 0 ? void 0 : _c.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) { 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) { // Wait before retrying await new Promise(resolve => setTimeout(resolve, this.retryDelay)); // Retry with one less retry attempt return this.requestWithRetry(requestFn, retries - 1); } // If we can't retry, re-throw the error throw error; } } /** * 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 * * @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) { 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; const body = (init === null || init === void 0 ? void 0 : init.body) ? JSON.parse(init.body.toString()) : undefined; // Get OAuth token and add it to headers const token = await this.getOAuthToken(); if (!token) { throw new Error('Failed to get OAuth token'); } const authHeaders = { ...headers, 'Authorization': `Bearer ${token.access_token}` }; // Use retry logic for the request const response = await this.requestWithRetry(() => this.axiosInstance.request({ url, method, headers: authHeaders, data: body, })); // Normalize response if enabled and not already normalized by interceptor if (this.normalizeResponses && response.data) { const originalData = response.data; response.data = this.normalizeResponseFormat(response.data); // Debug log for normalization result if (process.env.DEBUG === 'true') { const isChanged = JSON.stringify(originalData) !== JSON.stringify(response.data); console.debug(`[SapClient] customFetch: ${isChanged ? 'Response format normalized' : 'No normalization applied'} for ${method} ${url}`, isChanged ? `\nFormat detected: ${Array.isArray(response.data) ? `Array with ${response.data.length} items` : typeof response.data === 'object' ? `Object with keys [${Object.keys(response.data).join(', ')}]` : `${typeof response.data}`}` : ''); } } // Convert axios response to fetch Response return new Response(JSON.stringify(response.data), { status: response.status, statusText: response.statusText, headers: new Headers(response.headers), }); } catch (error) { const requestMethod = (init === null || init === void 0 ? void 0 : init.method) || 'GET'; const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; throw this.enhanceError(error, `Request failed: ${requestMethod} ${requestUrl}`); } } /** * Ensures a CSRF token is available for write operations * - Fetches a new token if one doesn't exist * - Uses OAuth token for authentication * * @private * @returns {Promise<void>} * @throws {Error} If the CSRF token cannot be fetched */ async ensureCsrfToken() { if (!this.csrfToken) { try { // Get OAuth token first const token = await this.getOAuthToken(); // Extract base URL without /api/v1 for CSRF token request const baseUrlWithoutApiPath = this.baseUrl.replace(/\/api\/v1\/?$/, ''); const response = await axios_1.default.get(`${baseUrlWithoutApiPath}/`, { headers: { 'X-CSRF-Token': 'Fetch', 'Authorization': `Bearer ${token.access_token}` }, }); this.csrfToken = response.headers['x-csrf-token']; } catch (error) { throw this.enhanceError(error, 'Failed to fetch CSRF token'); } } } /** * Initialisiert alle benutzerdefinierten Client-Erweiterungen */ initializeCustomClients() { // Integration Content Advanced Client this.integrationContentAdvanced = this.getOrCreateCustomClient(custom_client_registry_1.CustomClientType.INTEGRATION_CONTENT_ADVANCED, this.integrationContent); // In Zukunft weitere Custom-Clients hier initialisieren } /** * Gibt einen vorhandenen benutzerdefinierten Client zurück oder erstellt einen neuen * * @param type - Der Typ des benutzerdefinierten Clients * @param baseClient - Der zugrundeliegende Standard-Client * @returns Eine Instanz des benutzerdefinierten Clients */ getOrCreateCustomClient(type, baseClient) { // Prüfen, ob der Client bereits erstellt wurde if (this.customClients.has(type)) { return this.customClients.get(type); } // Neuen Client erstellen const customClient = this.customClientRegistry.create(type, baseClient); this.customClients.set(type, customClient); return customClient; } /** * Gibt einen benutzerdefinierten Client nach Typ zurück * * @param type - Der Typ des benutzerdefinierten Clients * @returns Der benutzerdefinierte Client oder undefined, wenn nicht gefunden */ getCustomClient(type) { return this.customClients.get(type); } /** * Registriert einen benutzerdefinierten Client-Factory * * Diese Methode erlaubt es, zur Laufzeit neue benutzerdefinierte Client-Factories * zu registrieren, was die dynamische Erweiterung des Clients ermöglicht. * * @param type - Der Typ des benutzerdefinierten Clients * @param factory - Die Factory für die Erstellung des Clients */ registerCustomClientFactory(type, factory) { this.customClientRegistry.register(type, factory); } } /** * Export the SapClient class as default export */ exports.default = SapClient;