Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 43 additions & 95 deletions src/frontend/src/api/apiClient.tsx
Original file line number Diff line number Diff line change
@@ -1,104 +1,52 @@
import { headerBuilder, getApiUrl } from './config';

// Helper function to build URL with query parameters
const buildUrl = (url: string, params?: Record<string, any>): string => {
if (!params) return url;

const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});

const queryString = searchParams.toString();
return queryString ? `${url}?${queryString}` : url;
};

// Fetch with Authentication Headers
const fetchWithAuth = async (url: string, method: string = "GET", body: BodyInit | null = null) => {
const token = localStorage.getItem('token'); // Get the token from localStorage
const authHeaders = headerBuilder(); // Get authentication headers

const headers: Record<string, string> = {
...authHeaders, // Include auth headers from headerBuilder
};

if (token) {
headers['Authorization'] = `Bearer ${token}`; // Add the token to the Authorization header
}

// If body is FormData, do not set Content-Type header
if (body && body instanceof FormData) {
delete headers['Content-Type'];
} else {
headers['Content-Type'] = 'application/json';
body = body ? JSON.stringify(body) : null;
/**
* API Client — thin adapter over the centralized httpClient.
*
* Auth headers (x-ms-client-principal-id, Authorization) are now injected
* automatically by httpClient's request interceptor, eliminating all manual
* headerBuilder() / localStorage.getItem('token') calls.
*/
import httpClient from './httpClient';
import { getApiUrl } from './config';

/**
* Ensure httpClient's base URL stays in sync with the runtime config.
* Called lazily on every request so it picks up late-initialized API_URL.
*/
function syncBaseUrl(): void {
const apiUrl = getApiUrl();
if (apiUrl && httpClient.getBaseUrl() !== apiUrl) {
httpClient.setBaseUrl(apiUrl);
}
}

const options: RequestInit = {
method,
headers,
body: body || undefined,
};

try {
const apiUrl = getApiUrl();
const finalUrl = `${apiUrl}${url}`;
// Log the request details
const response = await fetch(finalUrl, options);

if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Something went wrong');
}

const isJson = response.headers.get('content-type')?.includes('application/json');
const responseData = isJson ? await response.json() : null;
return responseData;
} catch (error) {
console.info('API Error:', (error as Error).message);
throw error;
}
};
export const apiClient = {
get: <T = any>(url: string, config?: { params?: Record<string, unknown> }): Promise<T> => {
syncBaseUrl();
return httpClient.get<T>(url, { params: config?.params });
},

// Vanilla Fetch without Auth for Login
const fetchWithoutAuth = async (url: string, method: string = "POST", body: BodyInit | null = null) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
post: <T = any>(url: string, body?: unknown): Promise<T> => {
syncBaseUrl();
return httpClient.post<T>(url, body);
},

const options: RequestInit = {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
};
put: <T = any>(url: string, body?: unknown): Promise<T> => {
syncBaseUrl();
return httpClient.put<T>(url, body);
},

try {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}${url}`, options);
delete: <T = any>(url: string): Promise<T> => {
syncBaseUrl();
return httpClient.del<T>(url);
},

if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Login failed');
}
const isJson = response.headers.get('content-type')?.includes('application/json');
return isJson ? await response.json() : null;
} catch (error) {
console.log('Login Error:', (error as Error).message);
throw error;
}
};
upload: <T = any>(url: string, formData: FormData): Promise<T> => {
syncBaseUrl();
return httpClient.upload<T>(url, formData);
},

// Authenticated requests (with token) and login (without token)
export const apiClient = {
get: (url: string, config?: { params?: Record<string, any> }) => {
const finalUrl = buildUrl(url, config?.params);
return fetchWithAuth(finalUrl, 'GET');
login: <T = any>(url: string, body?: unknown): Promise<T> => {
syncBaseUrl();
return httpClient.postWithoutAuth<T>(url, body);
},
post: (url: string, body?: any) => fetchWithAuth(url, 'POST', body),
put: (url: string, body?: any) => fetchWithAuth(url, 'PUT', body),
delete: (url: string) => fetchWithAuth(url, 'DELETE'),
upload: (url: string, formData: FormData) => fetchWithAuth(url, 'POST', formData),
login: (url: string, body?: any) => fetchWithoutAuth(url, 'POST', body), // For login without auth
};
10 changes: 0 additions & 10 deletions src/frontend/src/api/apiService.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@ export class APIService {
if (!data) {
throw new Error(`Plan with ID ${planId} not found`);
}
console.log('Fetched plan by ID:', data);
const results = {
plan: data.plan as Plan,
messages: data.messages as AgentMessageBE[],
Expand Down Expand Up @@ -190,8 +189,6 @@ export class APIService {
const requestKey = `approve-plan-${planApprovalData.m_plan_id}`;

return this._requestTracker.trackRequest(requestKey, async () => {
console.log('📤 Approving plan via v4 API:', planApprovalData);

const response = await apiClient.post(API_ENDPOINTS.PLAN_APPROVAL, planApprovalData);

// Invalidate cache since plan execution will start
Expand All @@ -200,7 +197,6 @@ export class APIService {
this._cache.invalidate(new RegExp(`^plan.*_${planApprovalData.plan_id}`));
}

console.log('✅ Plan approval successful:', response);
return response;
});
}
Expand Down Expand Up @@ -260,13 +256,7 @@ export class APIService {
return response;
}
async sendAgentMessage(data: AgentMessageResponse): Promise<AgentMessage> {
const t0 = performance.now();
const result = await apiClient.post(API_ENDPOINTS.AGENT_MESSAGE, data);
console.log('[agent_message] sent', {
ms: +(performance.now() - t0).toFixed(1),
agent: data.agent,
type: data.agent_type
});
return result;
}
}
Expand Down
149 changes: 149 additions & 0 deletions src/frontend/src/api/apiUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* API Utility Functions
*
* Centralized helpers for error response construction, retry logic,
* and request deduplication. Single source of truth — eliminates
* duplicated error patterns across API functions.
*/

/**
* Create a standardized error response object.
* Replaces repeated `{ ...new Response(), ok: false, status: 500 }` patterns.
*/
export function createErrorResponse(status: number, message: string): Response {
return new Response(JSON.stringify({ error: message }), {
status,
statusText: message,
headers: { 'Content-Type': 'application/json' },
});
}

/**
* Retry a request with exponential backoff.
* @param fn - The async function to retry
* @param maxRetries - Maximum number of retry attempts (default: 3)
* @param baseDelay - Base delay in ms before exponential increase (default: 1000)
*/
export async function retryRequest<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
const delay = baseDelay * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error('Max retries exceeded');
}

/**
* Request cache with TTL and deduplication of in-flight requests.
* Prevents duplicate API calls for the same data.
*/
interface CacheEntry<T> {
data: T;
timestamp: number;
expiresAt: number;
}

export class RequestCache {
private cache = new Map<string, CacheEntry<unknown>>();
private pendingRequests = new Map<string, Promise<unknown>>();

/** Get cached data or fetch it, deduplicating concurrent identical requests */
async get<T>(
key: string,
fetcher: () => Promise<T>,
ttlMs = 30000
): Promise<T> {
// Return cached data if still fresh
const cached = this.cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return cached.data as T;
}

// Deduplicate concurrent identical requests
const pending = this.pendingRequests.get(key);
if (pending) {
return pending as Promise<T>;
}

const request = fetcher()
.then((data) => {
this.cache.set(key, {
data,
timestamp: Date.now(),
expiresAt: Date.now() + ttlMs,
});
this.pendingRequests.delete(key);
return data;
})
.catch((error) => {
this.pendingRequests.delete(key);
throw error;
});

this.pendingRequests.set(key, request);
return request;
}

/** Invalidate cached entries matching a key pattern */
invalidate(pattern?: string | RegExp): void {
if (!pattern) {
this.cache.clear();
return;
}
for (const key of this.cache.keys()) {
const matches = typeof pattern === 'string'
? key.includes(pattern)
: pattern.test(key);
if (matches) this.cache.delete(key);
}
}

/** Clear all cached data */
clear(): void {
this.cache.clear();
this.pendingRequests.clear();
}
}

/** Shared request cache singleton */
export const requestCache = new RequestCache();

/**
* Debounce utility — delays calling `fn` until `delayMs` has elapsed
* since the last invocation.
*/
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delayMs: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delayMs);
};
}

/**
* Throttle utility — ensures `fn` is called at most once per `limitMs`.
*/
export function throttle<T extends (...args: unknown[]) => void>(
fn: T,
limitMs: number
): (...args: Parameters<T>) => void {
let lastCall = 0;
return (...args: Parameters<T>) => {
const now = Date.now();
if (now - lastCall >= limitMs) {
lastCall = now;
fn(...args);
}
};
}
23 changes: 0 additions & 23 deletions src/frontend/src/api/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,6 @@ export async function getUserInfo(): Promise<UserInfo> {
try {
const response = await fetch("/.auth/me");
if (!response.ok) {
console.log(
"No identity provider found. Access to chat will be blocked."
);
return {} as UserInfo;
}
const payload = await response.json();
Expand Down Expand Up @@ -97,40 +94,20 @@ export function getUserInfoGlobal() {
}

if (!USER_INFO) {
// console.info('User info not yet configured');
return null;
}

return USER_INFO;
}

export function getUserId(): string {
// USER_ID = getUserInfoGlobal()?.user_id || null;
if (!USER_ID) {
USER_ID = getUserInfoGlobal()?.user_id || null;
}
const userId = USER_ID ?? "00000000-0000-0000-0000-000000000000";
return userId;
}

/**
* Build headers with authentication information
* @param headers Optional additional headers to merge
* @returns Combined headers object with authentication
*/
export function headerBuilder(headers?: Record<string, string>): Record<string, string> {
let userId = getUserId();
//console.log('headerBuilder: Using user ID:', userId);
let defaultHeaders = {
"x-ms-client-principal-id": String(userId) || "", // Custom header
};
//console.log('headerBuilder: Created headers:', defaultHeaders);
return {
...defaultHeaders,
...(headers ? headers : {})
};
}

export const toBoolean = (value: any): boolean => {
if (typeof value !== 'string') {
return false;
Expand Down
Loading
Loading