import * as plugins from './plugins.js'; import { logger } from './unifi.logger.js'; import type { THttpMethod } from './interfaces/index.js'; /** * Extended response type */ export interface IUnifiHttpResponse { ok: boolean; status: number; statusText?: string; headers?: Record; body: T; text: () => Promise; json: () => Promise; } /** * Base HTTP client for UniFi APIs with SSL handling and authentication support */ export class UnifiHttp { private baseUrl: string; private headers: Record = {}; private verifySsl: boolean; private cookies: string[] = []; constructor(baseUrl: string, verifySsl: boolean = false) { this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash this.verifySsl = verifySsl; } /** * Set a header value */ public setHeader(name: string, value: string): void { this.headers[name] = value; } /** * Remove a header */ public removeHeader(name: string): void { delete this.headers[name]; } /** * Set cookies from Set-Cookie headers */ public setCookies(cookies: string[]): void { this.cookies = cookies; } /** * Get the cookie header string */ public getCookieHeader(): string { return this.cookies .map((cookie) => cookie.split(';')[0]) .join('; '); } /** * Build request with common options */ private buildRequest(url: string, data?: unknown): plugins.smartrequest.SmartRequestClient { let requestBuilder = plugins.smartrequest.SmartRequestClient.create() .url(url) .header('Content-Type', 'application/json'); // Add stored headers for (const [name, value] of Object.entries(this.headers)) { requestBuilder = requestBuilder.header(name, value); } // Add cookies const cookieHeader = this.getCookieHeader(); if (cookieHeader) { requestBuilder = requestBuilder.header('Cookie', cookieHeader); } // Add JSON data if (data) { requestBuilder = requestBuilder.json(data); } return requestBuilder; } /** * Make an HTTP request */ public async request( method: THttpMethod, endpoint: string, data?: unknown ): Promise { const url = `${this.baseUrl}${endpoint}`; logger.log('debug', `UniFi HTTP ${method} ${url}`); const requestBuilder = this.buildRequest(url, data); let response: plugins.smartrequest.IExtendedIncomingMessage; // Note: smartrequest v2 doesn't have built-in SSL bypass, but Node's default is fine for most cases // For self-signed certs, we need to handle at the environment level or use NODE_TLS_REJECT_UNAUTHORIZED const originalRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED; if (!this.verifySsl) { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; } try { switch (method) { case 'GET': response = await requestBuilder.get(); break; case 'POST': response = await requestBuilder.post(); break; case 'PUT': response = await requestBuilder.put(); break; case 'DELETE': response = await requestBuilder.delete(); break; case 'PATCH': response = await requestBuilder.patch(); break; default: throw new Error(`Unsupported HTTP method: ${method}`); } } finally { if (!this.verifySsl) { if (originalRejectUnauthorized !== undefined) { process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalRejectUnauthorized; } else { delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; } } } // Store cookies from response const setCookieHeaders = response.headers?.['set-cookie']; if (setCookieHeaders && Array.isArray(setCookieHeaders)) { this.setCookies(setCookieHeaders); } // Check for HTTP errors const statusCode = response.statusCode || 0; if (statusCode >= 400) { const errorBody = typeof response.body === 'string' ? response.body : JSON.stringify(response.body); logger.log('error', `UniFi HTTP error: ${statusCode} - ${errorBody}`); throw new Error(`HTTP ${statusCode}: ${response.statusMessage || 'Unknown'} - ${errorBody}`); } // Return body directly return response.body as T; } /** * Make a raw request returning the full response (for handling cookies/headers) */ public async rawRequest( method: THttpMethod, endpoint: string, data?: unknown ): Promise { const url = `${this.baseUrl}${endpoint}`; logger.log('debug', `UniFi HTTP raw ${method} ${url}`); const requestBuilder = this.buildRequest(url, data); let response: plugins.smartrequest.IExtendedIncomingMessage; const originalRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED; if (!this.verifySsl) { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; } try { switch (method) { case 'GET': response = await requestBuilder.get(); break; case 'POST': response = await requestBuilder.post(); break; case 'PUT': response = await requestBuilder.put(); break; case 'DELETE': response = await requestBuilder.delete(); break; case 'PATCH': response = await requestBuilder.patch(); break; default: throw new Error(`Unsupported HTTP method: ${method}`); } } finally { if (!this.verifySsl) { if (originalRejectUnauthorized !== undefined) { process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalRejectUnauthorized; } else { delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; } } } // Store cookies from response const setCookieHeaders = response.headers?.['set-cookie']; if (setCookieHeaders && Array.isArray(setCookieHeaders)) { this.setCookies(setCookieHeaders); } const statusCode = response.statusCode || 0; const body = response.body; return { ok: statusCode >= 200 && statusCode < 300, status: statusCode, statusText: response.statusMessage, headers: response.headers as Record, body, text: async () => typeof body === 'string' ? body : JSON.stringify(body), json: async () => typeof body === 'object' ? body : JSON.parse(body as string), }; } /** * Shorthand for GET requests */ public async get(endpoint: string): Promise { return this.request('GET', endpoint); } /** * Shorthand for POST requests */ public async post(endpoint: string, data?: unknown): Promise { return this.request('POST', endpoint, data); } /** * Shorthand for PUT requests */ public async put(endpoint: string, data?: unknown): Promise { return this.request('PUT', endpoint, data); } /** * Shorthand for DELETE requests */ public async delete(endpoint: string): Promise { return this.request('DELETE', endpoint); } /** * Shorthand for PATCH requests */ public async patch(endpoint: string, data?: unknown): Promise { return this.request('PATCH', endpoint, data); } }