Files
omada-api/src/client/OmadaClient.ts
2025-12-21 14:17:54 -08:00

183 lines
5.1 KiB
TypeScript

/**
* Core API client for Omada Controller REST API
*/
import https from 'https';
import { Authentication, AuthConfig } from '../auth/Authentication.js';
import {
OmadaApiError,
OmadaNetworkError,
OmadaDeviceNotFoundError,
OmadaConfigurationError,
} from '../errors/OmadaErrors.js';
import { ApiResponse, ApiRequestOptions, PaginatedResponse } from '../types/api.js';
export interface OmadaClientConfig extends AuthConfig {
siteId?: string;
}
/**
* Main client class for interacting with Omada Controller API
*/
export class OmadaClient {
private auth: Authentication;
private config: OmadaClientConfig;
private httpsAgent: https.Agent;
private siteId: string | null = null;
constructor(config: OmadaClientConfig) {
this.config = config;
this.auth = new Authentication(config);
this.httpsAgent = new https.Agent({
rejectUnauthorized: config.verifySSL ?? true,
});
this.siteId = config.siteId || null;
}
/**
* Get the current site ID (auto-detect if not set)
*/
async getSiteId(): Promise<string> {
if (this.siteId) {
return this.siteId;
}
// Auto-detect site ID by getting the default site
const sites = await this.request<Array<{ id: string; name: string }>>('GET', '/sites');
if (!sites || sites.length === 0) {
throw new OmadaApiError('No sites found in Omada Controller');
}
// Use the first site as default
this.siteId = sites[0].id;
return this.siteId;
}
/**
* Set the site ID explicitly
*/
setSiteId(siteId: string): void {
this.siteId = siteId;
}
/**
* Make an authenticated API request
*/
async request<T = any>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
endpoint: string,
options: Omit<ApiRequestOptions, 'method'> = {}
): Promise<T> {
const token = await this.auth.getAccessToken();
const url = this.buildUrl(endpoint, options.params);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...options.headers,
};
try {
const response = await fetch(url, {
method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
// Note: Native fetch in Node.js 18+ doesn't support agent directly
// For SSL certificate handling, ensure verifySSL config is set correctly
// @ts-expect-error - agent may not be supported by all fetch implementations
agent: this.httpsAgent,
});
const text = await response.text();
let data: ApiResponse<T>;
try {
data = JSON.parse(text);
} catch (parseError) {
throw new OmadaApiError(
`Invalid JSON response: ${text.substring(0, 200)}`,
response.status
);
}
if (!response.ok) {
throw new OmadaApiError(
data.msg || `HTTP ${response.status}: ${response.statusText}`,
response.status,
data
);
}
// Check Omada API error code (0 = success)
if (data.errorCode !== 0) {
const errorMsg = data.msg || `API error code: ${data.errorCode}`;
// Handle specific error cases
if (data.errorCode === 10001) {
// Token expired or invalid
this.auth.clearToken();
throw new OmadaApiError('Authentication token expired', 401, data);
}
if (data.errorCode === 10002 || data.errorCode === 10003) {
throw new OmadaDeviceNotFoundError(endpoint);
}
if (data.errorCode >= 10000 && data.errorCode < 20000) {
throw new OmadaConfigurationError(errorMsg, data);
}
throw new OmadaApiError(errorMsg, response.status, data);
}
return data.result as T;
} catch (error) {
if (error instanceof OmadaApiError || error instanceof OmadaDeviceNotFoundError || error instanceof OmadaConfigurationError) {
throw error;
}
throw new OmadaNetworkError(
`Request failed: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined
);
}
}
/**
* Make a paginated API request
*/
async requestPaginated<T = any>(
method: 'GET' | 'POST',
endpoint: string,
options: Omit<ApiRequestOptions, 'method'> = {}
): Promise<PaginatedResponse<T>> {
const result = await this.request<PaginatedResponse<T>>(method, endpoint, options);
return result;
}
/**
* Build full URL with query parameters
*/
private buildUrl(endpoint: string, params?: Record<string, string | number | boolean>): string {
const baseUrl = this.config.baseUrl.replace(/\/$/, '');
let url = `${baseUrl}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
if (params && Object.keys(params).length > 0) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
searchParams.append(key, String(value));
}
url += `?${searchParams.toString()}`;
}
return url;
}
/**
* Get authentication instance (for advanced use cases)
*/
getAuth(): Authentication {
return this.auth;
}
}