lib/immich/api.ex

defmodule Immich.API do
  @moduledoc """
  Public facade for the most common Immich API workflows.

  This module combines:

  - OAuth bootstrap (`authorize/2`, `callback/3`)
  - Authenticated user lookup (`current_user/2`)
  - Sync stream consumption (`sync_stream/3`)
  - Sync acknowledgement posting (`sync_ack/3`)

  Most request functions accept `shared_opts` so callers can inject a custom
  client implementation (for example in tests) while keeping the same API.

  ## Typical usage

  1. Call `authorize/2` and open the returned login URL.
  2. After redirect, call `callback/3` to build an authenticated `Session`.
  3. Use that `Session` with `current_user/2`, `sync_stream/3`, and `sync_ack/3`.
  """

  alias Immich.API.Client
  alias Immich.API.OAuth
  alias Immich.API.Session

  @typedoc """
  Standard API error shape returned by the configured client.

  This includes transport failures, authentication/authorization failures,
  and non-success HTTP responses mapped by `Immich.API.Client`.
  """
  @type api_error :: Client.api_error()

  @typedoc """
  Options shared by authenticated API functions.

  Supported options:

  - `:client` - module implementing `Immich.API.Client.Behaviour`
    (defaults to `Immich.API.Client`)
  """
  @type shared_opts :: [
          client: Client.t()
        ]

  @doc """
  Initiates the OAuth flow by requesting PKCE parameters and an authorization URL.

  Returns the login URL and OAuth context required by `callback/3`.

  This delegates to `Immich.API.OAuth.authorize/2`.

  See `Immich.API.OAuth.authorize/2` for details.
  """
  @spec authorize(OAuth.redirect_uri(), OAuth.shared_opts()) ::
          {:ok, OAuth.login_url(), OAuth.oauth_context()} | {:error, OAuth.oauth_error()}
  defdelegate authorize(redirect_uri, opts), to: OAuth

  @doc """
  Completes OAuth callback exchange and returns an authenticated session.

  The provided `oauth_context` must be the exact context returned by `authorize/2`.

  This delegates to `Immich.API.OAuth.callback/3`.

  See `Immich.API.OAuth.callback/3` for details.
  """
  @spec callback(OAuth.callback_uri(), OAuth.oauth_context(), OAuth.shared_opts()) ::
          {:ok, Session.t()} | {:error, OAuth.oauth_error()}
  defdelegate callback(callback_uri, oauth_context, shared_opts), to: OAuth

  @doc """
  Fetches the currently authenticated user for a session.

  Uses `session.base_url` to build `/api/users/me` and attaches a bearer token
  from `session.access_token`.
  """
  @spec current_user(Session.t(), shared_opts()) :: {:ok, map()} | {:error, api_error()}
  def current_user(session, opts \\ []) do
    api_get(session, "/api/users/me", opts)
  end

  @typedoc """
  Sync type identifier sent to Immich sync APIs.

  Values are expected to match server-recognized type names (for example
  `"AssetsV1"`, `"StacksV1"`).
  """
  @type sync_type :: String.t()

  @typedoc """
  Options for `sync_stream/3`.

  Supported options:

  - all `shared_opts`
  - `:reset?` - when `true`, requests a reset sync from the server
    (defaults to `false`)
  """
  @type sync_stream_opts :: [
          {:reset?, boolean()} | {:client, Client.t()}
        ]

  @doc """
  Opens the Immich sync stream and returns decoded NDJSON events.

  The request payload is forwarded as:

  - `"types"` from `sync_types`
  - `"reset"` from `opts[:reset?]` (defaults to `false`)

  Returned events are yielded as a lazy enumerable of decoded maps.
  """
  @spec sync_stream(Session.t(), [sync_type()], sync_stream_opts()) ::
          {:ok, Enumerable.t(map())} | {:error, api_error()}
  def sync_stream(session, sync_types, opts \\ []) do
    reset? = Keyword.get(opts, :reset?, false)
    request = %{"reset" => reset?, "types" => sync_types}
    api_ndjson_stream(session, "/api/sync/stream", request, opts)
  end

  @typedoc """
  Sync acknowledgement identifier sent back to the server.

  Each acknowledgement corresponds to an event previously received from
  `sync_stream/3`.
  """
  @type sync_ack :: String.t()

  @doc """
  Posts sync acknowledgements.

  Sends acknowledgements to `/api/sync/ack` using the authenticated session.
  """
  @spec sync_ack(Session.t(), [sync_ack()], shared_opts()) ::
          {:ok, map() | term()} | {:error, api_error()}
  def sync_ack(session, acks, opts \\ []) do
    request = %{"acks" => acks}
    api_post(session, "/api/sync/ack", request, opts)
  end

  defp api_get(session, path, opts) do
    client(opts).get(build_url(session, path), authorization_headers(session))
  end

  defp api_post(session, path, request, opts) do
    client(opts).post(build_url(session, path), request, authorization_headers(session))
  end

  defp api_ndjson_stream(session, path, request, opts) do
    client(opts).ndjson_stream(
      build_url(session, path),
      request,
      authorization_headers(session)
    )
  end

  defp client(opts) do
    Keyword.get(opts, :client, Client)
  end

  defp build_url(session, path) do
    URI.merge(session.base_url, path)
  end

  defp authorization_headers(session),
    do: [{"authorization", "Bearer #{session.access_token}"}]
end