import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { AuthToken, ApiResponse, QueryParams, UMBRELLA_ENDPOINTS } from './types.js';
export class UmbrellaApiClient {
private baseURL: string;
private axiosInstance: AxiosInstance;
private _authToken: AuthToken | null = null;
private _auth: any = null; // Reference to UmbrellaDualAuth for cloud context switching
constructor(baseURL: string) {
this.baseURL = baseURL;
this.axiosInstance = axios.create({
baseURL: this.baseURL,
timeout: 60000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
}
setAuthToken(token: AuthToken): void {
this._authToken = token;
}
setAuthHeaders(headers: AuthToken): void {
this._authToken = headers;
}
setAuth(auth: any): void {
this._auth = auth;
}
get authToken(): AuthToken | null {
return this._authToken;
}
private getAuthHeaders(cloudContext?: string, customerAccountKey?: string): Record<string, string> {
if (!this._authToken) {
throw new Error('Authentication token not set. Please authenticate first.');
}
const headers: Record<string, string> = {
'Authorization': this._authToken.Authorization
};
// Priority: Customer account key > Cloud context key > Default key
let apiKey = null;
// 1. Customer-specific account key (highest priority)
if (customerAccountKey && this._auth && typeof this._auth.buildCustomerApiKey === 'function') {
apiKey = this._auth.buildCustomerApiKey(customerAccountKey);
if (apiKey) {
console.error(`[API-CLIENT] Using customer account key ${customerAccountKey}: ${apiKey}`);
}
}
// 2. Cloud-specific API key (if no customer key or customer key failed)
if (!apiKey && cloudContext && this._auth && typeof this._auth.buildCloudContextApiKey === 'function') {
apiKey = this._auth.buildCloudContextApiKey(cloudContext);
if (apiKey) {
console.error(`[API-CLIENT] Using ${cloudContext.toUpperCase()} cloud context API key: ${apiKey}`);
}
}
// 3. Default API key (fallback)
if (!apiKey && this._authToken.apikey) {
apiKey = this._authToken.apikey;
console.error(`[API-CLIENT] Using default API key: ${apiKey}`);
}
if (apiKey) {
headers['apikey'] = apiKey;
}
return headers;
}
async makeRequest(path: string, params?: QueryParams): Promise<ApiResponse> {
try {
// Special handling for MSP customers endpoint
if (path === '/msp/customers') {
console.error(`[API-CLIENT] MSP customers endpoint detected - using enhanced method`);
return await this.getMspCustomers();
}
return await this.makeDirectRequest(path, params);
} catch (error: any) {
// Error handling is done in makeDirectRequest
throw error;
}
}
async makeDirectRequest(path: string, params?: QueryParams): Promise<ApiResponse> {
try {
// Extract cloud context and customer account key from parameters
const cloudContext = params?.cloud_context;
const customerAccountKey = params?.customer_account_key;
// Convert params to handle arrays properly for axios
const axiosParams: Record<string, any> = {};
if (params) {
for (const [key, value] of Object.entries(params)) {
// Skip internal parameters that shouldn't be sent to API
if (key === 'customer_account_key') {
continue;
}
if (Array.isArray(value)) {
// Axios handles arrays by repeating the parameter
axiosParams[key] = value;
} else if (value !== undefined && value !== null) {
axiosParams[key] = value;
}
}
}
const config: AxiosRequestConfig = {
headers: this.getAuthHeaders(cloudContext, customerAccountKey),
params: axiosParams
};
// DEBUGGING: Log the full request details
const fullUrl = `${this.baseURL}${path}`;
// Properly handle array parameters and filter parameters
const searchParams = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
// Handle filter parameters (e.g., filters[service]=AmazonCloudWatch)
if (key.startsWith('filters[')) {
// Keep the filter format as-is for the API
searchParams.append(key, String(value));
} else if (Array.isArray(value)) {
// Add each array item as separate parameter
value.forEach(item => searchParams.append(key, String(item)));
} else if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
}
}
const queryString = searchParams.toString();
const finalUrl = queryString ? `${fullUrl}?${queryString}` : fullUrl;
console.error('\\n🔍 API REQUEST:');
console.error(` URL: ${finalUrl}`);
console.error(` Cloud Context: ${cloudContext || 'none'}`);
console.error(` Customer Account: ${customerAccountKey || 'none'}`);
console.error(` Headers: ${JSON.stringify(this.getAuthHeaders(cloudContext, customerAccountKey), null, 2)}`);
console.error(` Params: ${JSON.stringify(params, null, 2)}`);
const response = await this.axiosInstance.get(path, config);
// DEBUGGING: Log the response details
console.error('\\n✅ API RESPONSE:');
console.error(` Status: ${response.status}`);
console.error(` Headers: ${JSON.stringify(response.headers, null, 2)}`);
console.error(` Data: ${JSON.stringify(response.data, null, 2)}`);
console.error(' =====================================');
return {
data: response.data,
success: true,
message: 'Request completed successfully'
};
} catch (error: any) {
// DEBUGGING: Log the error details
console.error('\\n❌ API ERROR:');
if (error.response) {
console.error(` Status: ${error.response.status}`);
console.error(` Headers: ${JSON.stringify(error.response.headers, null, 2)}`);
console.error(` Data: ${JSON.stringify(error.response.data, null, 2)}`);
} else if (error.request) {
console.error(` Request Error: ${error.message}`);
console.error(` URL: ${error.config?.url}`);
} else {
console.error(` General Error: ${error.message}`);
}
console.error(' =====================================');
if (error.response) {
const status = error.response.status;
const message = error.response.data?.message || error.response.statusText || 'Request failed';
if (status === 401) {
return {
success: false,
error: 'Authentication failed. Please check your credentials and try again.',
message: message
};
} else if (status === 403) {
return {
success: false,
error: 'Access denied. You do not have permission to access this resource.',
message: message
};
} else if (status === 404) {
return {
success: false,
error: 'Resource not found. Please check the endpoint path.',
message: message
};
} else if (status === 429) {
return {
success: false,
error: 'Rate limit exceeded. Please wait and try again.',
message: message
};
} else {
return {
success: false,
error: `Server error (${status}): ${message}`,
message: message
};
}
} else if (error.request) {
return {
success: false,
error: 'Network error: Unable to connect to Umbrella Cost API.',
message: 'Please check your network connection and try again.'
};
} else {
return {
success: false,
error: `Request error: ${error.message}`,
message: error.message
};
}
}
}
// Helper methods for common endpoints
async getCostAndUsage(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/invoices/caui', params);
}
async getCosts(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/invoices/caui', params);
}
async getAccounts(): Promise<ApiResponse> {
// DISABLED: This method corrupts session state and breaks recommendations
// Returning empty response to prevent session corruption
console.error('[GETACCOUNTS] Disabled to prevent session corruption and preserve recommendations');
return { success: false, error: 'getAccounts disabled to prevent session corruption' };
}
async getPublicCostAndUsage(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/invoices/cost-and-usage', params);
}
async getRDSInstanceCosts(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/usage/rds/instance-costs', params);
}
async getS3BucketCosts(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/usage/s3/bucket-costs', params);
}
async getResourceExplorerData(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/usage/resource-explorer/resource', params);
}
async getDashboards(): Promise<ApiResponse> {
return this.makeRequest('/dashboards');
}
async getBudgets(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/budgets', params);
}
async getRecommendations(params?: QueryParams & { useNetAmortized?: boolean, cloud_context?: string }): Promise<ApiResponse> {
// Use the correct V2 recommendations API with POST method and commonParams header
// DEFAULT: Use last 12 months date range to match UI exactly
const today = new Date();
const last12Months = new Date(today);
last12Months.setFullYear(today.getFullYear() - 1);
const fromDate = last12Months.toISOString().split('T')[0];
const toDate = today.toISOString().split('T')[0];
const requestBody = {
filters: {
user_status: {
excluded: false,
done: false
},
status_filter: "potential_savings",
open_recs_creation_date: {
from: fromDate,
to: toDate
}
},
sort: [{
by: "savings",
order: "desc"
}],
page_size: 2000,
pagination_token: null
};
try {
// Fetch ALL recommendations through pagination from working V2 API
let allRecommendations: any[] = [];
let paginationToken: string | null = null;
let pageCount = 0;
do {
const pageRequestBody: any = {
...requestBody,
pagination_token: paginationToken
};
const response: any = await axios.post('https://api.umbrellacost.io/api/v2/recommendations/list', pageRequestBody, {
headers: {
...this.getAuthHeaders(),
'commonParams': '{"isPpApplied":false}',
'Content-Type': 'application/json'
},
timeout: 30000
});
// V2 API returns { page: [...], pagination_token: "..." } structure
const pageData = response.data.page || [];
allRecommendations.push(...pageData);
paginationToken = response.data.pagination_token;
pageCount++;
console.error(`📄 Fetched page ${pageCount}: ${pageData.length} recommendations (token: ${paginationToken ? 'has-next' : 'end'})`);
// Safety limit to prevent infinite loops
if (pageCount > 20) break;
} while (paginationToken);
console.error(`📊 Total fetched: ${allRecommendations.length} recommendations across ${pageCount} pages`);
// Debug: Log structure of first recommendation to understand field names
if (allRecommendations.length > 0) {
console.error(`🔍 First recommendation structure:`, JSON.stringify(allRecommendations[0], null, 2));
}
const recommendations = allRecommendations;
// Filter recommendations based on cloud context (if specified)
let filteredRecommendations = recommendations;
if (params?.cloud_context) {
const targetCloud = params.cloud_context.toUpperCase();
filteredRecommendations = recommendations.filter((rec: any) => {
return rec.cloudProvider === targetCloud;
});
console.error(`🎯 ${targetCloud} Filtering: ${allRecommendations.length} total -> ${filteredRecommendations.length} ${targetCloud} recommendations`);
} else {
// No cloud filter specified - return ALL cloud recommendations
console.error(`🌐 No cloud filter: Returning all ${allRecommendations.length} recommendations`);
}
// DEFAULT: Use on-demand (unblended) costs unless client explicitly requests net amortized
const useNetAmortized = params?.useNetAmortized === true;
const savingsField = useNetAmortized ? 'netAmortized' : 'unblended';
const costField = useNetAmortized ? 'netAmortized' : 'unblended';
const totalSavings = filteredRecommendations.reduce((sum: number, rec: any) =>
sum + (rec.annualSavings?.[savingsField] || 0), 0);
// Calculate category breakdown from filtered recommendations
const categoryBreakdown: Record<string, {amount: number, count: number}> = {};
filteredRecommendations.forEach((rec: any) => {
const category = rec.category || 'Unknown';
if (!categoryBreakdown[category]) categoryBreakdown[category] = {amount: 0, count: 0};
categoryBreakdown[category].amount += rec.annualSavings?.[savingsField] || 0;
categoryBreakdown[category].count += 1;
});
const formattedRecommendations = filteredRecommendations.slice(0, 10).map((rec: any) => ({
id: rec.recId,
type: rec.typeName || rec.typeId,
category: rec.category,
service: rec.service,
resource_id: rec.resourceId,
resource_name: rec.resourceName || rec.resourceId,
region: rec.region,
annual_savings: rec.annualSavings?.[savingsField] || 0,
monthly_savings: rec.monthlySavings?.[savingsField] || 0,
annual_current_cost: rec.annualCurrentCost?.[costField] || 0,
monthly_current_cost: rec.monthlyCurrentCost?.[costField] || 0,
current_instance_type: rec.instanceType,
recommended_instance_type: rec.recData?.instance_type_recommended || rec.recData?.recommended_instance_type,
recommended_action: rec.recommendedAction,
description: rec.recData?.description || `${rec.typeName} recommendation for ${rec.service}`,
status: rec.open ? 'Open' : 'Closed',
age_days: rec.age,
cost_view: useNetAmortized ? 'net_amortized' : 'on_demand_unblended'
}));
return {
success: true,
data: formattedRecommendations,
total_count: filteredRecommendations.length,
total_potential_savings: totalSavings,
category_breakdown: categoryBreakdown,
message: `Found ${filteredRecommendations.length} cost optimization recommendations with $${totalSavings.toLocaleString()} total potential annual savings`
};
} catch (error: any) {
console.error('Recommendations API Error:', error.message);
if (error.response?.status === 500) {
return {
success: false,
error: 'Recommendations service temporarily unavailable',
message: 'The recommendations feature may not be configured for this account'
};
}
throw error;
}
}
async getAnomalyDetection(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/anomaly-detection', params);
}
async getCommitmentAnalysis(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/commitment', params);
}
async getKubernetesCosts(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/kubernetes', params);
}
async getUsers(): Promise<ApiResponse> {
return this.makeRequest('/users');
}
async getCustomers(): Promise<ApiResponse> {
return this.makeRequest('/user-management/customers');
}
async getMspCustomers(): Promise<ApiResponse> {
console.error(`[API-CLIENT] MSP Customer List: Using auth-based approach directly (pagination API ignores limit parameters)`);
// Skip pagination entirely since it doesn't work - API ignores limit/offset parameters
// and always returns only 5 customers regardless of pagination settings.
// Authentication data shows 300+ accounts available, so use that directly.
try {
return await this.getMspCustomersFromAuth();
} catch (error: any) {
console.error(`[API-CLIENT] MSP Customer List: Auth-based approach failed: ${error.message}`);
return {
success: false,
error: 'Unable to retrieve MSP customer list from authentication data',
message: `MSP customer retrieval failed: ${error.message}`
};
}
}
private async getMspCustomersFromAuth(): Promise<ApiResponse> {
console.error(`[API-CLIENT] MSP Customer List: Using auth-based approach as fallback`);
if (this._auth && typeof this._auth.getAvailableAccounts === 'function') {
const availableAccounts = this._auth.getAvailableAccounts();
console.error(`[API-CLIENT] MSP Customer List: Found ${availableAccounts.length} available accounts from auth`);
if (availableAccounts.length > 5) {
// Convert ALL available accounts to customer format (less filtering for maximum results)
const customerAccounts = availableAccounts
.filter((acc: any) => {
const name = acc.accountName || acc.account_name || acc.name || '';
const accountKey = acc.accountKey || acc.account_key || acc.accountId || acc.account_id || '';
// Minimal filtering - only exclude truly invalid entries
return name.trim() !== '' && accountKey !== '' && accountKey !== 'undefined';
})
.map((acc: any, index: number) => ({
customerName: acc.accountName || acc.account_name || acc.name || `Account ${acc.accountKey}`,
customerCode: `MSP-${acc.accountKey || index}`,
customerId: index + 1,
linkedAccountIds: [String(acc.accountKey || acc.account_key || acc.accountId || acc.account_id)],
customerNameId: acc.accountName || acc.account_name || acc.name || `Account ${acc.accountKey}`,
accountKey: acc.accountKey || acc.account_key || acc.accountId || acc.account_id
}));
console.error(`[API-CLIENT] MSP Customer List: Converted ${availableAccounts.length} auth accounts to ${customerAccounts.length} customer records`);
return {
data: customerAccounts,
success: true,
message: `Found ${customerAccounts.length} MSP customers from authentication data (fallback method)`
};
}
}
return {
success: false,
error: 'Unable to retrieve MSP customer list - pagination and auth fallback both failed',
message: 'MSP customer data not available'
};
}
async getDistinctServiceNames(): Promise<ApiResponse> {
return this.makeRequest('/invoices/service-names/distinct');
}
async getDistinctServiceCosts(): Promise<ApiResponse> {
return this.makeRequest('/invoices/service-costs/distinct');
}
// Generic method to get available endpoints
getAvailableEndpoints() {
return UMBRELLA_ENDPOINTS;
}
}