mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
- Added new scripts for server development and full application startup in package.json. - Enhanced project management by checking for existing projects to avoid duplicates. - Improved API integration with better error handling and connection checks in the Electron API. - Updated UI components to reflect changes in project and session management. - Refactored authentication status display to include more detailed information on methods used.
665 lines
20 KiB
TypeScript
665 lines
20 KiB
TypeScript
/**
|
|
* HTTP API Client for web mode
|
|
*
|
|
* This client provides the same API as the Electron IPC bridge,
|
|
* but communicates with the backend server via HTTP/WebSocket.
|
|
*/
|
|
|
|
import type {
|
|
ElectronAPI,
|
|
FileResult,
|
|
WriteResult,
|
|
ReaddirResult,
|
|
StatResult,
|
|
DialogResult,
|
|
SaveImageResult,
|
|
AutoModeAPI,
|
|
FeaturesAPI,
|
|
SuggestionsAPI,
|
|
SpecRegenerationAPI,
|
|
AutoModeEvent,
|
|
SuggestionsEvent,
|
|
SpecRegenerationEvent,
|
|
FeatureSuggestion,
|
|
SuggestionType,
|
|
} from "./electron";
|
|
import type { Feature } from "@/store/app-store";
|
|
import type {
|
|
WorktreeAPI,
|
|
GitAPI,
|
|
ModelDefinition,
|
|
ProviderStatus,
|
|
} from "@/types/electron";
|
|
|
|
// Check if we're in simplified Electron mode (Electron with HTTP backend)
|
|
const isSimplifiedElectronMode = (): boolean => {
|
|
if (typeof window === "undefined") return false;
|
|
const api = (window as any).electronAPI;
|
|
// Simplified mode has isElectron flag but limited methods
|
|
return api?.isElectron === true && typeof api?.getServerUrl === "function";
|
|
};
|
|
|
|
// Check if native Electron dialogs are available
|
|
const hasNativeDialogs = (): boolean => {
|
|
if (typeof window === "undefined") return false;
|
|
const api = (window as any).electronAPI;
|
|
return typeof api?.openDirectory === "function";
|
|
};
|
|
|
|
// Server URL - configurable via environment variable or Electron
|
|
const getServerUrl = async (): Promise<string> => {
|
|
if (typeof window !== "undefined") {
|
|
// In simplified Electron mode, get URL from main process
|
|
const api = (window as any).electronAPI;
|
|
if (api?.getServerUrl) {
|
|
try {
|
|
return await api.getServerUrl();
|
|
} catch {
|
|
// Fall through to defaults
|
|
}
|
|
}
|
|
|
|
// Check for environment variable
|
|
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
|
|
if (envUrl) return envUrl;
|
|
|
|
// Default to localhost for development
|
|
return "http://localhost:3008";
|
|
}
|
|
return "http://localhost:3008";
|
|
};
|
|
|
|
// Synchronous version for constructor (uses default, then updates)
|
|
const getServerUrlSync = (): string => {
|
|
if (typeof window !== "undefined") {
|
|
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
|
|
if (envUrl) return envUrl;
|
|
}
|
|
return "http://localhost:3008";
|
|
};
|
|
|
|
// Get API key from environment variable
|
|
const getApiKey = (): string | null => {
|
|
if (typeof window !== "undefined") {
|
|
return process.env.NEXT_PUBLIC_AUTOMAKER_API_KEY || null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
type EventType =
|
|
| "agent:stream"
|
|
| "auto-mode:event"
|
|
| "suggestions:event"
|
|
| "spec-regeneration:event";
|
|
|
|
type EventCallback = (payload: unknown) => void;
|
|
|
|
/**
|
|
* HTTP API Client that implements ElectronAPI interface
|
|
*/
|
|
export class HttpApiClient implements ElectronAPI {
|
|
private serverUrl: string;
|
|
private ws: WebSocket | null = null;
|
|
private eventCallbacks: Map<EventType, Set<EventCallback>> = new Map();
|
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
private isConnecting = false;
|
|
|
|
constructor() {
|
|
this.serverUrl = getServerUrlSync();
|
|
// Update server URL asynchronously if in Electron
|
|
this.initServerUrl();
|
|
this.connectWebSocket();
|
|
}
|
|
|
|
private async initServerUrl(): Promise<void> {
|
|
const url = await getServerUrl();
|
|
if (url !== this.serverUrl) {
|
|
this.serverUrl = url;
|
|
// Reconnect WebSocket with new URL
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
this.connectWebSocket();
|
|
}
|
|
}
|
|
|
|
private connectWebSocket(): void {
|
|
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
|
return;
|
|
}
|
|
|
|
this.isConnecting = true;
|
|
|
|
try {
|
|
const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/api/events";
|
|
this.ws = new WebSocket(wsUrl);
|
|
|
|
this.ws.onopen = () => {
|
|
console.log("[HttpApiClient] WebSocket connected");
|
|
this.isConnecting = false;
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
const callbacks = this.eventCallbacks.get(data.type);
|
|
if (callbacks) {
|
|
callbacks.forEach((cb) => cb(data.payload));
|
|
}
|
|
} catch (error) {
|
|
console.error("[HttpApiClient] Failed to parse WebSocket message:", error);
|
|
}
|
|
};
|
|
|
|
this.ws.onclose = () => {
|
|
console.log("[HttpApiClient] WebSocket disconnected");
|
|
this.isConnecting = false;
|
|
this.ws = null;
|
|
// Attempt to reconnect after 5 seconds
|
|
if (!this.reconnectTimer) {
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null;
|
|
this.connectWebSocket();
|
|
}, 5000);
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error("[HttpApiClient] WebSocket error:", error);
|
|
this.isConnecting = false;
|
|
};
|
|
} catch (error) {
|
|
console.error("[HttpApiClient] Failed to create WebSocket:", error);
|
|
this.isConnecting = false;
|
|
}
|
|
}
|
|
|
|
private subscribeToEvent(type: EventType, callback: EventCallback): () => void {
|
|
if (!this.eventCallbacks.has(type)) {
|
|
this.eventCallbacks.set(type, new Set());
|
|
}
|
|
this.eventCallbacks.get(type)!.add(callback);
|
|
|
|
// Ensure WebSocket is connected
|
|
this.connectWebSocket();
|
|
|
|
return () => {
|
|
const callbacks = this.eventCallbacks.get(type);
|
|
if (callbacks) {
|
|
callbacks.delete(callback);
|
|
}
|
|
};
|
|
}
|
|
|
|
private getHeaders(): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
const apiKey = getApiKey();
|
|
if (apiKey) {
|
|
headers["X-API-Key"] = apiKey;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
|
method: "POST",
|
|
headers: this.getHeaders(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
return response.json();
|
|
}
|
|
|
|
private async get<T>(endpoint: string): Promise<T> {
|
|
const headers = this.getHeaders();
|
|
const response = await fetch(`${this.serverUrl}${endpoint}`, { headers });
|
|
return response.json();
|
|
}
|
|
|
|
// Basic operations
|
|
async ping(): Promise<string> {
|
|
const result = await this.get<{ status: string }>("/api/health");
|
|
return result.status === "ok" ? "pong" : "error";
|
|
}
|
|
|
|
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
|
|
// Use native Electron shell if available (better UX)
|
|
if (hasNativeDialogs()) {
|
|
const api = (window as any).electronAPI;
|
|
if (api.openExternalLink) {
|
|
return api.openExternalLink(url);
|
|
}
|
|
}
|
|
// Web mode: open in new tab
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
return { success: true };
|
|
}
|
|
|
|
// File picker - uses native Electron dialogs when available, otherwise prompt
|
|
async openDirectory(): Promise<DialogResult> {
|
|
// Use native Electron dialog if available
|
|
if (hasNativeDialogs()) {
|
|
const api = (window as any).electronAPI;
|
|
return api.openDirectory();
|
|
}
|
|
|
|
// Web mode: show a modal to let user type/paste path
|
|
const path = prompt("Enter project directory path:");
|
|
if (!path) {
|
|
return { canceled: true, filePaths: [] };
|
|
}
|
|
|
|
// Validate with server
|
|
const result = await this.post<{
|
|
success: boolean;
|
|
path?: string;
|
|
error?: string;
|
|
}>("/api/fs/validate-path", { filePath: path });
|
|
|
|
if (result.success && result.path) {
|
|
return { canceled: false, filePaths: [result.path] };
|
|
}
|
|
|
|
alert(result.error || "Invalid path");
|
|
return { canceled: true, filePaths: [] };
|
|
}
|
|
|
|
async openFile(options?: object): Promise<DialogResult> {
|
|
// Use native Electron dialog if available
|
|
if (hasNativeDialogs()) {
|
|
const api = (window as any).electronAPI;
|
|
return api.openFile(options);
|
|
}
|
|
|
|
// Web mode: prompt for file path
|
|
const path = prompt("Enter file path:");
|
|
if (!path) {
|
|
return { canceled: true, filePaths: [] };
|
|
}
|
|
|
|
const result = await this.post<{ success: boolean; exists: boolean }>(
|
|
"/api/fs/exists",
|
|
{ filePath: path }
|
|
);
|
|
|
|
if (result.success && result.exists) {
|
|
return { canceled: false, filePaths: [path] };
|
|
}
|
|
|
|
alert("File does not exist");
|
|
return { canceled: true, filePaths: [] };
|
|
}
|
|
|
|
// File system operations
|
|
async readFile(filePath: string): Promise<FileResult> {
|
|
return this.post("/api/fs/read", { filePath });
|
|
}
|
|
|
|
async writeFile(filePath: string, content: string): Promise<WriteResult> {
|
|
return this.post("/api/fs/write", { filePath, content });
|
|
}
|
|
|
|
async mkdir(dirPath: string): Promise<WriteResult> {
|
|
return this.post("/api/fs/mkdir", { dirPath });
|
|
}
|
|
|
|
async readdir(dirPath: string): Promise<ReaddirResult> {
|
|
return this.post("/api/fs/readdir", { dirPath });
|
|
}
|
|
|
|
async exists(filePath: string): Promise<boolean> {
|
|
const result = await this.post<{ success: boolean; exists: boolean }>(
|
|
"/api/fs/exists",
|
|
{ filePath }
|
|
);
|
|
return result.exists;
|
|
}
|
|
|
|
async stat(filePath: string): Promise<StatResult> {
|
|
return this.post("/api/fs/stat", { filePath });
|
|
}
|
|
|
|
async deleteFile(filePath: string): Promise<WriteResult> {
|
|
return this.post("/api/fs/delete", { filePath });
|
|
}
|
|
|
|
async trashItem(filePath: string): Promise<WriteResult> {
|
|
// In web mode, trash is just delete
|
|
return this.deleteFile(filePath);
|
|
}
|
|
|
|
async getPath(name: string): Promise<string> {
|
|
// Server provides data directory
|
|
if (name === "userData") {
|
|
const result = await this.get<{ dataDir: string }>("/api/health/detailed");
|
|
return result.dataDir || "/data";
|
|
}
|
|
return `/data/${name}`;
|
|
}
|
|
|
|
async saveImageToTemp(
|
|
data: string,
|
|
filename: string,
|
|
mimeType: string,
|
|
projectPath?: string
|
|
): Promise<SaveImageResult> {
|
|
return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath });
|
|
}
|
|
|
|
// CLI checks - server-side
|
|
async checkClaudeCli(): Promise<{
|
|
success: boolean;
|
|
status?: string;
|
|
method?: string;
|
|
version?: string;
|
|
path?: string;
|
|
recommendation?: string;
|
|
installCommands?: {
|
|
macos?: string;
|
|
windows?: string;
|
|
linux?: string;
|
|
npm?: string;
|
|
};
|
|
error?: string;
|
|
}> {
|
|
return this.get("/api/setup/claude-status");
|
|
}
|
|
|
|
async checkCodexCli(): Promise<{
|
|
success: boolean;
|
|
status?: string;
|
|
method?: string;
|
|
version?: string;
|
|
path?: string;
|
|
hasApiKey?: boolean;
|
|
recommendation?: string;
|
|
installCommands?: {
|
|
macos?: string;
|
|
windows?: string;
|
|
linux?: string;
|
|
npm?: string;
|
|
};
|
|
error?: string;
|
|
}> {
|
|
return this.get("/api/setup/codex-status");
|
|
}
|
|
|
|
// Model API
|
|
model = {
|
|
getAvailable: async (): Promise<{
|
|
success: boolean;
|
|
models?: ModelDefinition[];
|
|
error?: string;
|
|
}> => {
|
|
return this.get("/api/models/available");
|
|
},
|
|
checkProviders: async (): Promise<{
|
|
success: boolean;
|
|
providers?: Record<string, ProviderStatus>;
|
|
error?: string;
|
|
}> => {
|
|
return this.get("/api/models/providers");
|
|
},
|
|
};
|
|
|
|
async testOpenAIConnection(apiKey?: string): Promise<{
|
|
success: boolean;
|
|
message?: string;
|
|
error?: string;
|
|
}> {
|
|
return this.post("/api/setup/test-openai", { apiKey });
|
|
}
|
|
|
|
// Setup API
|
|
setup = {
|
|
getClaudeStatus: (): Promise<{
|
|
success: boolean;
|
|
status?: string;
|
|
installed?: boolean;
|
|
method?: string;
|
|
version?: string;
|
|
path?: string;
|
|
auth?: {
|
|
authenticated: boolean;
|
|
method: string;
|
|
hasCredentialsFile?: boolean;
|
|
hasToken?: boolean;
|
|
hasStoredOAuthToken?: boolean;
|
|
hasStoredApiKey?: boolean;
|
|
hasEnvApiKey?: boolean;
|
|
hasEnvOAuthToken?: boolean;
|
|
};
|
|
error?: string;
|
|
}> => this.get("/api/setup/claude-status"),
|
|
|
|
getCodexStatus: (): Promise<{
|
|
success: boolean;
|
|
status?: string;
|
|
method?: string;
|
|
version?: string;
|
|
path?: string;
|
|
auth?: {
|
|
authenticated: boolean;
|
|
method: string;
|
|
hasAuthFile: boolean;
|
|
hasEnvKey: boolean;
|
|
hasStoredApiKey?: boolean;
|
|
hasEnvApiKey?: boolean;
|
|
};
|
|
error?: string;
|
|
}> => this.get("/api/setup/codex-status"),
|
|
|
|
installClaude: (): Promise<{
|
|
success: boolean;
|
|
message?: string;
|
|
error?: string;
|
|
}> => this.post("/api/setup/install-claude"),
|
|
|
|
installCodex: (): Promise<{
|
|
success: boolean;
|
|
message?: string;
|
|
error?: string;
|
|
}> => this.post("/api/setup/install-codex"),
|
|
|
|
authClaude: (): Promise<{
|
|
success: boolean;
|
|
token?: string;
|
|
requiresManualAuth?: boolean;
|
|
terminalOpened?: boolean;
|
|
command?: string;
|
|
error?: string;
|
|
message?: string;
|
|
output?: string;
|
|
}> => this.post("/api/setup/auth-claude"),
|
|
|
|
authCodex: (apiKey?: string): Promise<{
|
|
success: boolean;
|
|
requiresManualAuth?: boolean;
|
|
command?: string;
|
|
error?: string;
|
|
}> => this.post("/api/setup/auth-codex", { apiKey }),
|
|
|
|
storeApiKey: (provider: string, apiKey: string): Promise<{
|
|
success: boolean;
|
|
error?: string;
|
|
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
|
|
|
|
getApiKeys: (): Promise<{
|
|
success: boolean;
|
|
hasAnthropicKey: boolean;
|
|
hasOpenAIKey: boolean;
|
|
hasGoogleKey: boolean;
|
|
}> => this.get("/api/setup/api-keys"),
|
|
|
|
configureCodexMcp: (projectPath: string): Promise<{
|
|
success: boolean;
|
|
configPath?: string;
|
|
error?: string;
|
|
}> => this.post("/api/setup/configure-codex-mcp", { projectPath }),
|
|
|
|
getPlatform: (): Promise<{
|
|
success: boolean;
|
|
platform: string;
|
|
arch: string;
|
|
homeDir: string;
|
|
isWindows: boolean;
|
|
isMac: boolean;
|
|
isLinux: boolean;
|
|
}> => this.get("/api/setup/platform"),
|
|
|
|
onInstallProgress: (callback: (progress: unknown) => void) => {
|
|
return this.subscribeToEvent("agent:stream", callback);
|
|
},
|
|
|
|
onAuthProgress: (callback: (progress: unknown) => void) => {
|
|
return this.subscribeToEvent("agent:stream", callback);
|
|
},
|
|
};
|
|
|
|
// Features API
|
|
features: FeaturesAPI = {
|
|
getAll: (projectPath: string) =>
|
|
this.post("/api/features/list", { projectPath }),
|
|
get: (projectPath: string, featureId: string) =>
|
|
this.post("/api/features/get", { projectPath, featureId }),
|
|
create: (projectPath: string, feature: Feature) =>
|
|
this.post("/api/features/create", { projectPath, feature }),
|
|
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
|
|
this.post("/api/features/update", { projectPath, featureId, updates }),
|
|
delete: (projectPath: string, featureId: string) =>
|
|
this.post("/api/features/delete", { projectPath, featureId }),
|
|
getAgentOutput: (projectPath: string, featureId: string) =>
|
|
this.post("/api/features/agent-output", { projectPath, featureId }),
|
|
};
|
|
|
|
// Auto Mode API
|
|
autoMode: AutoModeAPI = {
|
|
start: (projectPath: string, maxConcurrency?: number) =>
|
|
this.post("/api/auto-mode/start", { projectPath, maxConcurrency }),
|
|
stop: (projectPath: string) =>
|
|
this.post("/api/auto-mode/stop", { projectPath }),
|
|
stopFeature: (featureId: string) =>
|
|
this.post("/api/auto-mode/stop-feature", { featureId }),
|
|
status: (projectPath?: string) =>
|
|
this.post("/api/auto-mode/status", { projectPath }),
|
|
runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
|
|
this.post("/api/auto-mode/run-feature", { projectPath, featureId, useWorktrees }),
|
|
verifyFeature: (projectPath: string, featureId: string) =>
|
|
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
|
|
resumeFeature: (projectPath: string, featureId: string) =>
|
|
this.post("/api/auto-mode/resume-feature", { projectPath, featureId }),
|
|
contextExists: (projectPath: string, featureId: string) =>
|
|
this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
|
|
analyzeProject: (projectPath: string) =>
|
|
this.post("/api/auto-mode/analyze-project", { projectPath }),
|
|
followUpFeature: (
|
|
projectPath: string,
|
|
featureId: string,
|
|
prompt: string,
|
|
imagePaths?: string[]
|
|
) =>
|
|
this.post("/api/auto-mode/follow-up-feature", {
|
|
projectPath,
|
|
featureId,
|
|
prompt,
|
|
imagePaths,
|
|
}),
|
|
commitFeature: (projectPath: string, featureId: string) =>
|
|
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
|
|
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
|
return this.subscribeToEvent("auto-mode:event", callback as EventCallback);
|
|
},
|
|
};
|
|
|
|
// Worktree API
|
|
worktree: WorktreeAPI = {
|
|
revertFeature: (projectPath: string, featureId: string) =>
|
|
this.post("/api/worktree/revert", { projectPath, featureId }),
|
|
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
|
|
this.post("/api/worktree/merge", { projectPath, featureId, options }),
|
|
getInfo: (projectPath: string, featureId: string) =>
|
|
this.post("/api/worktree/info", { projectPath, featureId }),
|
|
getStatus: (projectPath: string, featureId: string) =>
|
|
this.post("/api/worktree/status", { projectPath, featureId }),
|
|
list: (projectPath: string) =>
|
|
this.post("/api/worktree/list", { projectPath }),
|
|
getDiffs: (projectPath: string, featureId: string) =>
|
|
this.post("/api/worktree/diffs", { projectPath, featureId }),
|
|
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
|
|
this.post("/api/worktree/file-diff", { projectPath, featureId, filePath }),
|
|
};
|
|
|
|
// Git API
|
|
git: GitAPI = {
|
|
getDiffs: (projectPath: string) =>
|
|
this.post("/api/git/diffs", { projectPath }),
|
|
getFileDiff: (projectPath: string, filePath: string) =>
|
|
this.post("/api/git/file-diff", { projectPath, filePath }),
|
|
};
|
|
|
|
// Suggestions API
|
|
suggestions: SuggestionsAPI = {
|
|
generate: (projectPath: string, suggestionType?: SuggestionType) =>
|
|
this.post("/api/suggestions/generate", { projectPath, suggestionType }),
|
|
stop: () => this.post("/api/suggestions/stop"),
|
|
status: () => this.get("/api/suggestions/status"),
|
|
onEvent: (callback: (event: SuggestionsEvent) => void) => {
|
|
return this.subscribeToEvent("suggestions:event", callback as EventCallback);
|
|
},
|
|
};
|
|
|
|
// Spec Regeneration API
|
|
specRegeneration: SpecRegenerationAPI = {
|
|
create: (projectPath: string, projectOverview: string, generateFeatures?: boolean) =>
|
|
this.post("/api/spec-regeneration/create", {
|
|
projectPath,
|
|
projectOverview,
|
|
generateFeatures,
|
|
}),
|
|
generate: (projectPath: string, projectDefinition: string) =>
|
|
this.post("/api/spec-regeneration/generate", { projectPath, projectDefinition }),
|
|
generateFeatures: (projectPath: string) =>
|
|
this.post("/api/spec-regeneration/generate-features", { projectPath }),
|
|
stop: () => this.post("/api/spec-regeneration/stop"),
|
|
status: () => this.get("/api/spec-regeneration/status"),
|
|
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
|
|
return this.subscribeToEvent(
|
|
"spec-regeneration:event",
|
|
callback as EventCallback
|
|
);
|
|
},
|
|
};
|
|
|
|
// Running Agents API
|
|
runningAgents = {
|
|
getAll: (): Promise<{
|
|
success: boolean;
|
|
runningAgents?: Array<{
|
|
featureId: string;
|
|
projectPath: string;
|
|
projectName: string;
|
|
isAutoMode: boolean;
|
|
}>;
|
|
totalCount?: number;
|
|
autoLoopRunning?: boolean;
|
|
error?: string;
|
|
}> => this.get("/api/running-agents"),
|
|
};
|
|
}
|
|
|
|
// Singleton instance
|
|
let httpApiClientInstance: HttpApiClient | null = null;
|
|
|
|
export function getHttpApiClient(): HttpApiClient {
|
|
if (!httpApiClientInstance) {
|
|
httpApiClientInstance = new HttpApiClient();
|
|
}
|
|
return httpApiClientInstance;
|
|
}
|