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 <noreply@anthropic.com>
This commit is contained in:
1 parent
01e7d150d1
commit
9c00cd14ff
2 files changed
+238
No files matched your search
@@ -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<Response>) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<Response>;
|
||||
|
||||
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<UnifiOsSite | null> {
|
||||
const page = await this.requestAsync("/sites", sitePageSchema);
|
||||
return page.data[0] ?? null;
|
||||
}
|
||||
|
||||
public async getDevicesAsync(siteId: string): Promise<UnifiOsDevice[]> {
|
||||
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<UnifiOsDeviceStatistics> {
|
||||
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<UnifiOsClientCounts> {
|
||||
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<TOutput>(path: string, schema: z.ZodType<TOutput>): Promise<TOutput> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user