183 lines
5.1 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
|