From 9c00cd14ff64aea0077ffd621954cbda30887b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zachary=20Gu=C3=A9not?= Date: Tue, 8 Sep 2026 14:48:23 +0200 Subject: [PATCH] feat(integrations): add UniFi OS local API client Implements HTTP client for UniFi OS console integration API with support for fetching site information, client counts, device statistics, and VPN server configs. Includes pagination handling with filter-based fallback when client count exceeds API limit. Co-Authored-By: Claude Opus 5 --- .../src/unifi-os/test/unifi-os-api.spec.ts | 119 ++++++++++++++++++ .../integrations/src/unifi-os/unifi-os-api.ts | 119 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts create mode 100644 packages/integrations/src/unifi-os/unifi-os-api.ts diff --git a/packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts b/packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts new file mode 100644 index 000000000..1643dea89 --- /dev/null +++ b/packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts @@ -0,0 +1,119 @@ +// @vitest-environment node +import { Response } from "undici"; +import { describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + +import { ResponseError } from "@homarr/common/server"; + +import { UnifiOsApi } from "../unifi-os-api"; + +const ORIGIN = "https://console.example.test"; +const API_KEY = "test-key"; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never; + +const page = (data: unknown[], totalCount = data.length, limit = 200) => ({ + offset: 0, + limit, + count: data.length, + totalCount, + data, +}); + +const createApi = (handler: (url: string) => Promise) => { + const fetchAsync = vi.fn(async (url: string) => await handler(url)); + return { api: new UnifiOsApi(ORIGIN, API_KEY, fetchAsync as never), fetchAsync }; +}; + +describe("UnifiOsApi request shape", () => { + test("prefixes the integration path and sends the API key header", async () => { + const fetchAsync = vi.fn(async () => jsonResponse({ applicationVersion: "10.6.102" })); + const api = new UnifiOsApi(ORIGIN, API_KEY, fetchAsync as never); + + await api.getInfoAsync(); + + expect(fetchAsync).toHaveBeenCalledWith( + `${ORIGIN}/proxy/network/integration/v1/info`, + expect.objectContaining({ headers: expect.objectContaining({ "X-API-KEY": API_KEY }) }), + ); + }); + + test("does not double a slash when the origin has a trailing one", async () => { + const fetchAsync = vi.fn(async () => jsonResponse({ applicationVersion: "10.6.102" })); + const api = new UnifiOsApi(`${ORIGIN}/`, API_KEY, fetchAsync as never); + + await api.getInfoAsync(); + + expect(fetchAsync.mock.calls[0]?.[0]).toBe(`${ORIGIN}/proxy/network/integration/v1/info`); + }); + + test("throws ResponseError on a non-ok status instead of parsing the body", async () => { + const { api } = createApi(async () => jsonResponse({ error: { code: 401 } }, 401)); + + await expect(api.getInfoAsync()).rejects.toBeInstanceOf(ResponseError); + }); +}); + +describe("UnifiOsApi getClientCountsAsync", () => { + test("counts from a single page when totalCount fits under the API limit", async () => { + const { api, fetchAsync } = createApi(async () => + jsonResponse( + page([ + { id: "1", name: "a", type: "WIRED", access: { type: "DEFAULT" } }, + { id: "2", name: "b", type: "WIRELESS", access: { type: "GUEST" } }, + ]), + ), + ); + + const counts = await api.getClientCountsAsync("site-1"); + + expect(counts).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 1 }); + expect(fetchAsync).toHaveBeenCalledTimes(1); + }); + + test("switches to filtered count queries when the site exceeds the 200 limit", async () => { + const requested: string[] = []; + const { api } = createApi(async (url) => { + requested.push(url); + if (!url.includes("filter=")) return jsonResponse(page([], 512)); + if (url.includes("WIRED")) return jsonResponse(page([], 400, 1)); + if (url.includes("WIRELESS")) return jsonResponse(page([], 100, 1)); + if (url.includes("TELEPORT")) return jsonResponse(page([], 2, 1)); + if (url.includes("VPN")) return jsonResponse(page([], 10, 1)); + return jsonResponse(page([], 5, 1)); + }); + + const counts = await api.getClientCountsAsync("site-1"); + + expect(counts).toEqual({ total: 512, wired: 400, wireless: 100, vpn: 12, guests: 5 }); + expect(requested.some((url) => url.includes("and("))).toBe(false); + expect(requested.filter((url) => url.includes("filter=")).length).toBe(5); + }); + + test("uses the prefix filter syntax the API actually accepts", async () => { + const requested: string[] = []; + const { api } = createApi(async (url) => { + requested.push(url); + return jsonResponse(page([], url.includes("filter=") ? 1 : 512, 1)); + }); + + await api.getClientCountsAsync("site-1"); + + const guestQuery = requested.find((url) => url.includes("GUEST")); + expect(decodeURIComponent(guestQuery ?? "")).toContain("access.type.eq('GUEST')"); + expect(decodeURIComponent(guestQuery ?? "")).not.toContain(" and "); + }); +}); + +describe("UnifiOsApi getFirstSiteAsync", () => { + test("returns null for a console exposing no site rather than throwing", async () => { + const { api } = createApi(async () => jsonResponse(page([]))); + + expect(await api.getFirstSiteAsync()).toBeNull(); + }); +}); diff --git a/packages/integrations/src/unifi-os/unifi-os-api.ts b/packages/integrations/src/unifi-os/unifi-os-api.ts new file mode 100644 index 000000000..2f63c430e --- /dev/null +++ b/packages/integrations/src/unifi-os/unifi-os-api.ts @@ -0,0 +1,119 @@ +import { ResponseError } from "@homarr/common/server"; +import type { z } from "zod/v4"; + +import type { UnifiOsClientCounts, UnifiOsDevice, UnifiOsDeviceStatistics, UnifiOsSite } from "./unifi-os-types"; +import { + countClients, + createUnifiOsPageSchema, + parseUnifiOsAsync, + unifiOsClientSchema, + unifiOsDeviceSchema, + unifiOsDeviceStatisticsSchema, + unifiOsInfoSchema, + unifiOsSiteSchema, + unifiOsVpnServerSchema, +} from "./unifi-os-types"; + +export type UnifiOsFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise; + +const INTEGRATION_PATH = "/proxy/network/integration/v1"; +const REQUEST_TIMEOUT_MS = 10_000; + +/** Plafond imposé par l'API : demander davantage renvoie quand même 200. */ +const MAX_PAGE_LIMIT = 200; + +const PARSE_ERROR_MESSAGE = "Invalid UniFi OS response"; + +const clientPageSchema = createUnifiOsPageSchema(unifiOsClientSchema); +const devicePageSchema = createUnifiOsPageSchema(unifiOsDeviceSchema); +const sitePageSchema = createUnifiOsPageSchema(unifiOsSiteSchema); +const vpnServerPageSchema = createUnifiOsPageSchema(unifiOsVpnServerSchema); + +/** + * Client de l'API d'intégration locale d'UniFi OS. + * + * Ne connaît ni Homarr ni la notion d'intégration : il ne sait que parler à une + * console. Le `fetch` est injecté pour que les tests n'aient pas à mocker un module. + */ +export class UnifiOsApi { + private readonly baseUrl: string; + + constructor( + origin: string, + private readonly apiKey: string, + private readonly fetchAsync: UnifiOsFetch, + ) { + this.baseUrl = `${origin.replace(/\/+$/, "")}${INTEGRATION_PATH}`; + } + + public async getInfoAsync(): Promise<{ applicationVersion: string }> { + return await this.requestAsync("/info", unifiOsInfoSchema); + } + + public async getFirstSiteAsync(): Promise { + const page = await this.requestAsync("/sites", sitePageSchema); + return page.data[0] ?? null; + } + + public async getDevicesAsync(siteId: string): Promise { + const page = await this.requestAsync( + `/sites/${encodeURIComponent(siteId)}/devices?limit=${MAX_PAGE_LIMIT}`, + devicePageSchema, + ); + return page.data; + } + + public async getDeviceStatisticsAsync(siteId: string, deviceId: string): Promise { + return await this.requestAsync( + `/sites/${encodeURIComponent(siteId)}/devices/${encodeURIComponent(deviceId)}/statistics/latest`, + unifiOsDeviceStatisticsSchema, + ); + } + + public async getVpnServersAsync(siteId: string): Promise<{ enabled: boolean }[]> { + const page = await this.requestAsync(`/sites/${encodeURIComponent(siteId)}/vpn/servers`, vpnServerPageSchema); + return page.data; + } + + /** + * Sous le plafond de 200 clients, une seule page suffit et le comptage se fait + * en mémoire. Au-delà, rapatrier tous les enregistrements pour n'en tirer que + * des compteurs coûterait plus cher que cinq requêtes bornées. + */ + public async getClientCountsAsync(siteId: string): Promise { + const path = `/sites/${encodeURIComponent(siteId)}/clients`; + const firstPage = await this.requestAsync(`${path}?limit=${MAX_PAGE_LIMIT}`, clientPageSchema); + + if (firstPage.totalCount <= MAX_PAGE_LIMIT) { + return countClients(firstPage.data); + } + + const countByFilterAsync = async (filter: string) => { + const page = await this.requestAsync(`${path}?limit=1&filter=${encodeURIComponent(filter)}`, clientPageSchema); + return page.totalCount; + }; + + const [wired, wireless, vpn, teleport, guests] = await Promise.all([ + countByFilterAsync("type.eq('WIRED')"), + countByFilterAsync("type.eq('WIRELESS')"), + countByFilterAsync("type.eq('VPN')"), + countByFilterAsync("type.eq('TELEPORT')"), + countByFilterAsync("access.type.eq('GUEST')"), + ]); + + return { total: firstPage.totalCount, wired, wireless, vpn: vpn + teleport, guests }; + } + + private async requestAsync(path: string, schema: z.ZodType): Promise { + const response = await this.fetchAsync(`${this.baseUrl}${path}`, { + headers: { "X-API-KEY": this.apiKey, Accept: "application/json" }, + timeout: REQUEST_TIMEOUT_MS, + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return await parseUnifiOsAsync(await response.json(), schema, PARSE_ERROR_MESSAGE); + } +}