UnifiApi.Client (UnifiApi v0.4.0)

Copy Markdown View Source

HTTP client wrapper for UniFi API requests.

This module handles authentication, base URL construction, SSL settings, pagination parameters, and response normalization. All API modules delegate to this client for HTTP operations.

Typically you create a client via UnifiApi.new/1 and pass it to API functions:

client = UnifiApi.new(base_url: "https://192.168.1.1", api_key: "my-key")
UnifiApi.Network.Sites.list(client)

Summary

Types

Which of the controller's four API surfaces a path belongs to.

Controller flavour. Decides the default path prefixes.

Functions

Decodes a cert fingerprint string into 32 raw SHA-256 bytes.

Performs a DELETE request.

Performs a GET request.

Performs a GET request returning the raw (non-JSON-decoded) body.

Performs a GET request against a legacy v1 endpoint and unwraps the %{"meta" => %{"rc" => "ok"}, "data" => [...]} envelope.

Returns the Network integration-API path prefix for client.

Creates a new API client.

Performs a PATCH request with a JSON body.

Performs a POST request with a JSON body.

Returns one of the four API path prefixes for client.

Returns the Protect integration-API path prefix for client.

Returns the legacy v1 Protect API path prefix for client.

Performs a PUT request with a JSON body.

Performs a GET request returning the raw Req.Response.t() struct.

Scrubs a response body into a short, safe diagnostic preview.

Creates a lazy stream that automatically paginates through results.

Generic page-number paginator for v2 endpoints that don't fit stream/3 or stream_v1/3.

Like stream/3 but for legacy v1 endpoints.

Returns the controller style this client was built for.

Returns the legacy v1 Network API path prefix for client.

Validates a resource ID before it is interpolated into a request path.

Types

api()

@type api() :: :network | :protect | :network_v1 | :protect_v1

Which of the controller's four API surfaces a path belongs to.

client()

@type client() :: Req.Request.t()

response()

@type response() :: {:ok, term()} | {:error, UnifiApi.Error.t()}

style()

@type style() :: :udm | :cloud_key

Controller flavour. Decides the default path prefixes.

Functions

decode_fingerprint!(fingerprint)

@spec decode_fingerprint!(String.t()) :: <<_::256>>

Decodes a cert fingerprint string into 32 raw SHA-256 bytes.

Accepts plain lowercase or uppercase hex, optionally prefixed with "sha256:" and/or separated by colons (the ssh-keygen format:

iex> UnifiApi.Client.decode_fingerprint!("AB:" |> String.duplicate(31) <> "AB")
<<0xAB::256>>

Raises ArgumentError if the input is not exactly 64 hex characters after stripping the optional "sha256:" prefix and colons.

Will be made defp in v0.5.0 once a higher-level cert_fingerprints_from_host/1 helper covers the only in-tree caller. Until then it remains public so operators can build their own fingerprint stores from ssh-keyscan output (the audit flagged the prior @doc false + public def as a doc/code mismatch — now documented as public).

delete(client, path, opts \\ [])

@spec delete(client(), String.t(), keyword()) :: response()

Performs a DELETE request.

Examples

Client.delete(client, "/v1/sites/abc/networks/net-1")

get(client, path, opts \\ [])

@spec get(client(), String.t(), keyword()) :: response()

Performs a GET request.

Options

  • :offset — pagination offset (default: 0)
  • :limit — page size (default: 25, max: 200)
  • :filter — UniFi filter expression (e.g. "type.eq(WIRELESS)")

Examples

Client.get(client, "/v1/sites")
Client.get(client, "/v1/sites/abc/clients", limit: 50, offset: 100)
Client.get(client, "/v1/sites/abc/clients", filter: "type.eq(WIRED)")

get_raw(client, path, opts \\ [])

@spec get_raw(client(), String.t(), keyword()) :: {:ok, binary()} | {:error, term()}

Performs a GET request returning the raw (non-JSON-decoded) body.

Used for binary responses like camera snapshots.

Options

  • :high_quality — request high-quality snapshot (boolean)

Examples

{:ok, jpeg_binary} = Client.get_raw(client, "/v1/cameras/cam-1/snapshot")
{:ok, jpeg_binary} = Client.get_raw(client, "/v1/cameras/cam-1/snapshot", high_quality: true)

get_v1(client, path, opts \\ [])

@spec get_v1(client(), String.t(), keyword()) :: response()

Performs a GET request against a legacy v1 endpoint and unwraps the %{"meta" => %{"rc" => "ok"}, "data" => [...]} envelope.

Used by UnifiApi.Network.Events, Alarms, ClientsLive, etc. — all the endpoints that require cookie + CSRF auth via UnifiApi.Auth.Cookie.

Returns:

  • {:ok, data} when meta.rc == "ok" (or no envelope is present).
  • {:error, %UnifiApi.ApiError{code: code}} when the controller returns meta.rc == "error" (e.g. code == "api.err.LoginRequired"). Note the HTTP status is normally 200 in this case — the failure lives in the body. Before v0.4.0 this was {:error, {:unifi_error, msg}}.
  • {:error, error} for transport / non-2xx responses, same as get/3.

Options

Same as get/3. The :params option is the most useful here:

Client.get_v1(client, "/proxy/network/api/s/default/stat/event",
  params: [_limit: 100, within: 24])

network_prefix(client)

@spec network_prefix(client()) :: String.t()

Returns the Network integration-API path prefix for client.

new(opts \\ [])

@spec new(keyword()) :: client()

Creates a new API client.

See UnifiApi.new/1 for options and examples.

Production-safe defaults (v0.4.0)

  • :receive_timeout — 30_000ms (Req default is 15s × 3 retries).
  • :connect_timeout — 5_000ms. Mint's default is 30s, so against a black-holed controller IP a single get/3 used to block 30s, retry, and block another 30s — ~61s, with no way to shorten it. Health-check loops need sub-minute detection.
  • :pool_timeout — 5_000ms (Req default 5s).
  • :max_retries — 1 (Req default 3 — too many for a controller that rotates CSRF mid-request).
  • retry backoff is clamped to 300s even when the controller asks for longer (see retry_transient/2).

Bringing your own Finch pool

Pass finch: MyApp.Finch (a name you started and configured yourself) to route requests through your own pool. Req 0.7 forbids pool options alongside a pool name, so that also makes transport security your responsibility: combining :finch with :cert_fingerprints, verify_ssl: false, or :connect_timeout raises rather than silently dropping them.

patch(client, path, body, opts \\ [])

@spec patch(client(), String.t(), term(), keyword()) :: response()

Performs a PATCH request with a JSON body.

Examples

Client.patch(client, "/v1/cameras/cam-1", %{name: "Front Door"})

post(client, path, body, opts \\ [])

@spec post(client(), String.t(), term(), keyword()) :: response()

Performs a POST request with a JSON body.

Examples

Client.post(client, "/v1/sites/abc/networks", %{name: "Guest"})

prefix(client, api)

@spec prefix(client(), api()) :: String.t()

Returns one of the four API path prefixes for client.

Prefixes are resolved once by new/1 and stashed on the request via Req.Request.put_private/3, so two clients pointed at controllers of different flavours coexist in one VM. Before v0.4.0 they lived in global Application env, which made that impossible and meant any consumer calling Application.put_env/3 at runtime raced every in-flight request.

api is one of:

  • :network — integration API, default "/proxy/network/integration".
  • :protect — integration API, default "/proxy/protect/integration".
  • :network_v1 — legacy /api/s/{site}/... and /v2/api/site/{site}/... endpoints (events, alarms, IDS, anomalies, historical clients, DPI, topology, ...) that Ubiquiti has not exposed under x-api-key. These need cookie + CSRF auth via UnifiApi.Auth.Cookie. Default "/proxy/network".
  • :protect_v1 — the cookie + CSRF-authed /proxy/protect/api/events log that predates the integration API. Default "/proxy/protect".

A client not built by new/1 (a hand-rolled Req.new/1, as the test suite uses) falls back to Application env and then to the :udm defaults, so both styles of construction keep working.

protect_prefix(client)

@spec protect_prefix(client()) :: String.t()

Returns the Protect integration-API path prefix for client.

protect_v1_prefix(client)

@spec protect_v1_prefix(client()) :: String.t()

Returns the legacy v1 Protect API path prefix for client.

put(client, path, body, opts \\ [])

@spec put(client(), String.t(), term(), keyword()) :: response()

Performs a PUT request with a JSON body.

Examples

Client.put(client, "/v1/sites/abc/networks/net-1", %{name: "Updated"})

raw_get(client, path, opts \\ [])

@spec raw_get(client(), String.t(), keyword()) ::
  {:ok, Req.Response.t()} | {:error, term()}

Performs a GET request returning the raw Req.Response.t() struct.

Used by callers that need the full response (status, headers, body) rather than the body-only / unwrapped-shape that get/3 and get_raw/3 produce. Routing through this entry point (rather than calling Req.get/2 directly) keeps future Client-level hardening (redaction, retry policy, telemetry) uniform across all HTTP in the lib.

Returns:

  • {:ok, %Req.Response{}} for any non-error HTTP transport (2xx, 3xx, 4xx, 5xx — the caller decides what to do with the status code).
  • {:error, reason} for transport-layer failure (timeout, DNS, connection refused).

Redirects are already disabled by Client.new/1 (redirect: false), so 3xx responses are surfaced verbatim — this is what UnifiApi.detect/1 and UnifiApi.ping/1 rely on.

scrub_body_preview(body)

@spec scrub_body_preview(term()) :: binary() | nil

Scrubs a response body into a short, safe diagnostic preview.

Used by UnifiApi.AuthError / UnifiApi.RateLimitError / UnifiApi.StreamError to avoid leaking raw bodies into logs or exception trackers (CWE-209 / OWASP A09). The returned binary is:

  • ≤ 128 characters (truncated with ),
  • URL-like patterns (https?://...) replaced with [url],
  • host-like patterns (foo.example.com, IPv4 literals) replaced with [host],
  • JSON-encoded when the body is a map/list.

Returns nil for empty bodies.

stream(client, path, opts \\ [])

@spec stream(client(), String.t(), keyword()) :: Enumerable.t()

Creates a lazy stream that automatically paginates through results.

Uses Stream.resource/3 to fetch pages on demand. Each page requests up to :limit items (default 200, the API maximum). The stream halts when a page returns fewer items than the limit, when :max_pages / :max_items is reached, or (post-v0.4.0) after a single RateLimitError retry-after backoff retries the same page.

Error contract (changed in v0.4.0 — breaking)

By default, a mid-stream API error no longer raises. The stream halts and yields {:error, reason, last_offset} as its final element, where last_offset is the offset of the failed page (so a caller can resume from last_offset after backoff). Consumers using Enum.to_list/1 receive [item, ..., {:error, reason, last_offset}] and should pattern-match the tail:

case Client.stream(client, path) |> Enum.to_list() do
  items ++ [{:error, reason, offset}] -> {:error, reason, offset}
  items -> {:ok, items}
end

The one exception is UnifiApi.RateLimitError: the stream honours the controller's Retry-After header once, sleeps, and retries the same page. A second consecutive 429 (or any other error class) terminates the stream and yields the standard error tuple.

Pass raise_errors: true to restore the v0.3 raise-on-error behaviour (raises UnifiApi.StreamError). The StreamError struct redacts host/path/body in message/1 (CWE-209 / OWASP A09).

Options

  • :limit — items per page (default: 200)
  • :filter — UniFi filter expression
  • :max_pages — halt after this many successful pages (default: unbounded — halts only on a short page or error).
  • :max_items — halt once this many items have been yielded; the final page is truncated to fit (default: unbounded).
  • :raise_errors — when true, raise StreamError on error instead of yielding an error tuple (default: false).

Examples

# Stream all items
Client.stream(client, "/v1/sites/abc/devices")
|> Enum.to_list()

# Stream with filter, take first 10
Client.stream(client, "/v1/sites/abc/clients", filter: "type.eq(WIRELESS)")
|> Enum.take(10)

# Bounded scan — at most 5 pages or 1000 items
Client.stream(client, "/v1/sites/abc/clients",
  max_pages: 5,
  max_items: 1000
) |> Enum.to_list()

# Count all wireless clients across pages
Client.stream(client, "/v1/sites/abc/clients", filter: "type.eq(WIRELESS)")
|> Enum.count()

stream_paged(fetch_page, opts \\ [])

@spec stream_paged(
  (non_neg_integer() -> response()),
  keyword()
) :: Enumerable.t()

Generic page-number paginator for v2 endpoints that don't fit stream/3 or stream_v1/3.

Takes a fetch_page function that receives the current cursor and returns {:ok, list} (the page of items) or {:error, reason}. Halts when a page returns fewer than :limit items.

Error contract (changed in v0.4.0 — breaking)

Like stream/3, a mid-stream error halts the stream and yields {:error, reason, last_cursor} as the final element by default. Pass raise_errors: true to raise UnifiApi.StreamError instead.

Options

  • :limit — items per page (default 500). Used to detect the last page (a short page halts the stream).
  • :start_at — initial cursor value (default 0; for endpoints that page from 1 set start_at: 1).
  • :increment — how much to advance the cursor between pages. Use 1 for pageNumber-style paging, or set to :limit (the page size) for offset-style paging.
  • :raise_errors — when true, raise StreamError on error (default: false).

Retry: stream_paged/2 invokes the caller's fetch_page closure, so per-page retry is controlled by the client you close over. Pass a retry: false client (e.g. Req.merge(client, retry: false)) — re-issuing a paginated request after a 15s backoff risks duplicates/stale data.

Examples

Client.stream_paged(
  fn page ->
    Client.get_v1(client, "/v2/api/site/default/system-log/all",
      params: [pageSize: 500, pageNumber: page])
  end,
  limit: 500
)

Used by UnifiApi.Network.ClientsHistory.stream/3 and UnifiApi.Network.SystemLog.stream/3.

stream_v1(client, path, opts \\ [])

@spec stream_v1(client(), String.t(), keyword()) :: Enumerable.t()

Like stream/3 but for legacy v1 endpoints.

Pages on _start / _limit (the v1 convention) rather than offset / limit, and unwraps the v1 response envelope via get_v1/3. Used by UnifiApi.Network.Events.stream/3, Alarms.stream/3, etc.

Error contract (changed in v0.4.0 — breaking)

Like stream/3, a mid-stream error halts the stream and yields {:error, reason, last_start} as the final element by default. Pass raise_errors: true to raise UnifiApi.StreamError instead.

Options

  • :limit — items per page (default: 500, the typical v1 cap)
  • :params — additional query params merged on every request (e.g. [within: 24] to time-window the entire stream)
  • :max_pages — halt after this many successful pages (default: unbounded).
  • :max_items — halt once this many items have been yielded; the final page is truncated to fit (default: unbounded).
  • :raise_errors — when true, raise StreamError on error (default: false).

style(client)

@spec style(client()) :: style()

Returns the controller style this client was built for.

:udm unless new/1 was given style: :cloud_key.

v1_prefix(client)

@spec v1_prefix(client()) :: String.t()

Returns the legacy v1 Network API path prefix for client.

validate_id!(id)

@spec validate_id!(binary()) :: binary() | no_return()

Validates a resource ID before it is interpolated into a request path.

IDs must be a binary whose characters are all in [A-Za-z0-9_-] and whose length is 1..64. Raises ArgumentError at the boundary so a bad id never reaches URL composition (CWE-22 / OWASP A03).

Examples

iex> UnifiApi.Client.validate_id!("default")
"default"

iex> UnifiApi.Client.validate_id!("cam-1")
"cam-1"